From c900c225d32116c7ac2519c723147f9ff7cf0148 Mon Sep 17 00:00:00 2001 From: mXaln Date: Tue, 26 May 2026 13:40:07 +0400 Subject: [PATCH] Added decompose components unit tests --- gradle/libs.versions.toml | 4 +- .../writer/usecases/PushTargetTranslation.kt | 2 +- .../writer/ComposeAppCommonTest.kt | 12 - .../usecases/AdvancedGogsRepoSearchTest.kt | 20 +- .../usecases/CheckForLatestReleaseTest.kt | 6 +- .../usecases/CloneRepositoryTest.kt | 10 +- .../usecases/CreateRepositoryTest.kt | 27 +- .../integration/usecases/DownloadIndexTest.kt | 10 +- .../DownloadResourceContainersTest.kt | 4 +- .../integration/usecases/GetRepositoryTest.kt | 23 +- .../usecases/GogsLoginLogoutTest.kt | 43 +- .../usecases/PullTargetTranslationTest.kt | 150 ++-- .../usecases/PushTargetTranslationTest.kt | 84 ++- .../usecases/RegisterSSHKeysTest.kt | 10 +- .../usecases/SearchGogsRepositoriesTest.kt | 16 +- .../usecases/SearchGogsUsersTest.kt | 12 +- .../usecases/UpdateCatalogsTest.kt | 9 +- .../integration/usecases/UpdateSourceTest.kt | 4 +- .../usecases/UploadCrashReportTest.kt | 12 +- .../usecases/UploadFeedbackTest.kt | 8 +- .../writer/unit/ui/BaseComponentTest.kt | 75 ++ .../writer/unit/ui/TestComponentContext.kt | 18 + .../unit/ui/crash/CrashComponentTest.kt | 153 ++++ .../unit/ui/devtools/DevToolsComponentTest.kt | 178 +++++ .../download/DownloadSourcesComponentTest.kt | 93 +++ .../ui/dialogs/export/ExportComponentTest.kt | 448 ++++++++++++ .../dialogs/feedback/FeedbackComponentTest.kt | 84 +++ .../ui/dialogs/import/ImportComponentTest.kt | 662 ++++++++++++++++++ .../dialogs/import/ImportUsfmComponentTest.kt | 96 +++ .../source/SelectSourcesComponentTest.kt | 186 +++++ .../update/UpdateLibraryComponentTest.kt | 190 +++++ .../unit/ui/draft/DraftComponentTest.kt | 147 ++++ .../writer/unit/ui/home/HomeComponentTest.kt | 551 +++++++++++++++ .../unit/ui/navigation/RootComponentTest.kt | 141 ++++ .../NewTranslationComponentTest.kt | 205 ++++++ .../ui/profile/LoginOfflineComponentTest.kt | 55 ++ .../ui/profile/LoginOnlineComponentTest.kt | 122 ++++ .../unit/ui/profile/ProfileComponentTest.kt | 246 +++++++ .../ui/profile/ProfileIndexComponentTest.kt | 47 ++ .../ui/profile/TermsOfUseComponentTest.kt | 82 +++ .../unit/ui/publish/PublishComponentTest.kt | 186 +++++ .../unit/ui/settings/SettingsComponentTest.kt | 253 +++++++ .../unit/ui/splash/SplashComponentTest.kt | 170 +++++ .../ui/translate/ChunkModeComponentTest.kt | 151 ++++ .../unit/ui/translate/LoadingComponentTest.kt | 14 + .../ui/translate/ReadModeComponentTest.kt | 114 +++ .../ui/translate/ReviewModeComponentTest.kt | 518 ++++++++++++++ .../ui/translate/TranslateComponentTest.kt | 143 ++++ .../usecases/PushTargetTranslationTest.kt | 1 + .../writer/unit/usecases/UpdateAppTest.kt | 1 + 50 files changed, 5578 insertions(+), 218 deletions(-) delete mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/ComposeAppCommonTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/BaseComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/TestComponentContext.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/crash/CrashComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/devtools/DevToolsComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/download/DownloadSourcesComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/export/ExportComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/feedback/FeedbackComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/import/ImportComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/import/ImportUsfmComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/source/SelectSourcesComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/update/UpdateLibraryComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/draft/DraftComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/home/HomeComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/navigation/RootComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/newtranslation/NewTranslationComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/LoginOfflineComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/LoginOnlineComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/ProfileComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/ProfileIndexComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/TermsOfUseComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/publish/PublishComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/settings/SettingsComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/splash/SplashComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ChunkModeComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/LoadingComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ReadModeComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ReviewModeComponentTest.kt create mode 100644 shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/TranslateComponentTest.kt diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c20b93c..1a82e92 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ composeMultiplatform = "1.11.0" kotlinx-coroutines = "1.11.0" kotlinx-serialization-json = "1.11.0" ktor = "3.5.0" -material3 = "1.10.0-alpha05" +material3 = "1.9.0" iconsExtended = "1.7.3" koin = "4.2.1" @@ -30,7 +30,7 @@ preferences-core = "1.2.1" filekit = "0.14.1" htmlconverter = "1.1.1" foundation = "1.11.2" -oshi-core = "7.1.0" +oshi-core = "7.2.1" logger = "3.0.3" foreground = "0.1.0" diff --git a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/usecases/PushTargetTranslation.kt b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/usecases/PushTargetTranslation.kt index ef1f09f..386ed55 100644 --- a/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/usecases/PushTargetTranslation.kt +++ b/shared/src/commonMain/kotlin/org/bibletranslationtools/writer/usecases/PushTargetTranslation.kt @@ -52,7 +52,7 @@ class PushTargetTranslation( return repository?.let { push(repo, repository.sshUrl, onProgress) - } ?: Result(Status.UNKNOWN, "Failed to get repository ${targetTranslation.id}") + } ?: Result(Status.UNKNOWN, null) } catch (e: Exception) { Logger.e(TAG, "Failed to push target translation", e) diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/ComposeAppCommonTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/ComposeAppCommonTest.kt deleted file mode 100644 index 82fa15d..0000000 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/ComposeAppCommonTest.kt +++ /dev/null @@ -1,12 +0,0 @@ -package org.bibletranslationtools.writer - -import kotlin.test.Test -import kotlin.test.assertEquals - -class ComposeAppCommonTest { - - @Test - fun example() { - assertEquals(3, 1 + 2) - } -} \ No newline at end of file diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/AdvancedGogsRepoSearchTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/AdvancedGogsRepoSearchTest.kt index 476c4c1..fc71f2c 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/AdvancedGogsRepoSearchTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/AdvancedGogsRepoSearchTest.kt @@ -26,7 +26,7 @@ class AdvancedGogsRepoSearchTest : BaseIntegrationTest() { } @Test - fun searchReposByUser() = runBlocking { + fun searchReposByUser() { val user = "test" server.enqueue(createUsersResponse()) @@ -37,25 +37,25 @@ class AdvancedGogsRepoSearchTest : BaseIntegrationTest() { val onProgress: (Float, String?) -> Unit = { _, message -> progressMessage = message } - val repos = advancedGogsRepoSearch.execute(user, "", 5, onProgress) + val repos = runBlocking { advancedGogsRepoSearch.execute(user, "", 5, onProgress) } assertTrue(repos.isNotEmpty()) assertFalse(progressMessage.isNullOrEmpty()) } @Test - fun searchReposByRepoName() = runBlocking { + fun searchReposByRepoName() { val repo = "_gen_" server.enqueue(createReposResponse()) server.enqueue(createRepoResponse()) - val repos = advancedGogsRepoSearch.execute("", repo, 5) + val repos = runBlocking { advancedGogsRepoSearch.execute("", repo, 5) } assertTrue(repos.isNotEmpty()) } @Test - fun searchReposByUserAndRepoName() = runBlocking { + fun searchReposByUserAndRepoName() { val user = "mxaln" val repo = "_gen_" @@ -63,27 +63,27 @@ class AdvancedGogsRepoSearchTest : BaseIntegrationTest() { server.enqueue(createReposResponse()) server.enqueue(createRepoResponse()) - val repos = advancedGogsRepoSearch.execute(user, repo, 5) + val repos = runBlocking { advancedGogsRepoSearch.execute(user, repo, 5) } assertTrue(repos.isNotEmpty()) } @Test - fun searchNonExistentUser() = runBlocking { + fun searchNonExistentUser() { val user = "non-existent-user" server.enqueue(createEmptyDataResponse()) - val repos = advancedGogsRepoSearch.execute(user, "", 5) + val repos = runBlocking { advancedGogsRepoSearch.execute(user, "", 5) } assertTrue(repos.isEmpty()) } @Test - fun searchNonExistentRepo() = runBlocking { + fun searchNonExistentRepo() { val repo = "non-existent-repo" server.enqueue(createEmptyDataResponse()) - val repos = advancedGogsRepoSearch.execute("", repo, 5) + val repos = runBlocking { advancedGogsRepoSearch.execute("", repo, 5) } assertTrue(repos.isEmpty()) } diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CheckForLatestReleaseTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CheckForLatestReleaseTest.kt index c55c81e..67390ff 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CheckForLatestReleaseTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CheckForLatestReleaseTest.kt @@ -1,6 +1,6 @@ package org.bibletranslationtools.writer.integration.usecases -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.bibletranslationtools.writer.BaseIntegrationTest import org.bibletranslationtools.writer.Platform import org.bibletranslationtools.writer.usecases.CheckForLatestRelease @@ -16,8 +16,8 @@ class CheckForLatestReleaseTest : BaseIntegrationTest() { private val platform: Platform by inject() @Test - fun checkForLatestRelease() = runTest { - val result = checkForLatestRelease.execute() + fun checkForLatestRelease() { + val result = runBlocking { checkForLatestRelease.execute() } if (result.release != null) { assertFalse( diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CloneRepositoryTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CloneRepositoryTest.kt index 8d97a99..588757a 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CloneRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CloneRepositoryTest.kt @@ -1,6 +1,6 @@ package org.bibletranslationtools.writer.integration.usecases -import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.runBlocking import org.bibletranslationtools.writer.BaseIntegrationTest import org.bibletranslationtools.writer.usecases.CloneRepository import org.junit.Assert.assertEquals @@ -16,14 +16,14 @@ class CloneRepositoryTest : BaseIntegrationTest() { private val cloneRepository: CloneRepository by inject() @Test - fun cloneRepositorySuccessfully() = runTest { + fun cloneRepositorySuccessfully() { val cloneUrl = "https://wacs.bibletranslationtools.org/WycliffeAssociates/en_ulb.git" var progressMessage: String? = null val onProgress: (Float, String?) -> Unit = { _, message -> progressMessage = message } - val result = cloneRepository.execute(cloneUrl, onProgress) + val result = runBlocking { cloneRepository.execute(cloneUrl, onProgress) } assertNotNull("Clone repository result should not be null", result) assertNotNull("Progress message should not be null", progressMessage) @@ -40,7 +40,7 @@ class CloneRepositoryTest : BaseIntegrationTest() { } @Test - fun cloneNonExistingRepositoryFailed() = runTest { + fun cloneNonExistingRepositoryFailed() { val cloneUrl = "https://wacs.bibletranslationtools.org/WycliffeAssociates/non_existing_repo.git" var progressMessage: String? = null @@ -48,7 +48,7 @@ class CloneRepositoryTest : BaseIntegrationTest() { progressMessage = message } - val result = cloneRepository.execute(cloneUrl, onProgress) + val result = runBlocking { cloneRepository.execute(cloneUrl, onProgress) } assertNotNull("Clone repository result should not be null", result) assertNotNull("Progress message should not be null", progressMessage) diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CreateRepositoryTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CreateRepositoryTest.kt index b8a6cae..b1c6d61 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CreateRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/CreateRepositoryTest.kt @@ -62,40 +62,40 @@ class CreateRepositoryTest : BaseIntegrationTest() { } @Test - fun createRepositoryWithAuthenticationSucceeds() = runBlocking { + fun createRepositoryWithAuthenticationSucceeds() { loginGogsUser() createRepoResponse(201) - val created = createRepository.execute(targetTranslation) + val created = runBlocking { createRepository.execute(targetTranslation) } assertTrue("Repository should be created when authenticated", created) } @Test - fun createRepositoryThatAlreadyExistsSucceeds() = runBlocking { + fun createRepositoryThatAlreadyExistsSucceeds() { loginGogsUser() createRepoResponse(409) - val created = createRepository.execute(targetTranslation) + val created = runBlocking { createRepository.execute(targetTranslation) } assertTrue("Repository should be created when authenticated", created) } @Test - fun createRepositoryServerError() = runBlocking { + fun createRepositoryServerError() { loginGogsUser() createRepoResponse(500) - val created = createRepository.execute(targetTranslation) + val created = runBlocking { createRepository.execute(targetTranslation) } assertFalse("Repository should not be created", created) } @Test - fun createRepositoryWithoutAuthenticationFails() = runBlocking { + fun createRepositoryWithoutAuthenticationFails() { var progressMessage: String? = null val onProgress: (Float, String?) -> Unit = { _, message -> progressMessage = message @@ -103,19 +103,16 @@ class CreateRepositoryTest : BaseIntegrationTest() { createRepoResponse(403) - val created = createRepository.execute(targetTranslation, onProgress) + val created = runBlocking { createRepository.execute(targetTranslation, onProgress) } assertFalse("Repository should not be created when not authenticated", created) assertNotNull("Progress message should not be null", progressMessage) } - private fun loginGogsUser() = runBlocking{ - profile.gogsUser = TestUtils.simulateLoginGogsUser( - platform, - server, - gogsLogin, - "test" - ) + private fun loginGogsUser() { + profile.gogsUser = runBlocking { + TestUtils.simulateLoginGogsUser(platform, server, gogsLogin, "test") + } } private fun createRepoResponse(responseCode: Int) { diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/DownloadIndexTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/DownloadIndexTest.kt index df649b9..a202770 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/DownloadIndexTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/DownloadIndexTest.kt @@ -32,7 +32,7 @@ class DownloadIndexTest : BaseIntegrationTest() { } @Test - fun downloadIndexSucceeds() = runBlocking { + fun downloadIndexSucceeds() { var progressMessage: String? = null val onProgress: (Float, String?) -> Unit = { _, message -> progressMessage = message @@ -44,7 +44,7 @@ class DownloadIndexTest : BaseIntegrationTest() { } assertTrue("Languages before should not be empty", languagesBefore.isNotEmpty()) - val downloaded = downloadIndex.download(onProgress) + val downloaded = runBlocking { downloadIndex.download(onProgress) } assertTrue("Download result should be true", downloaded) assertNotNull("Progress message should not be null", progressMessage) @@ -63,18 +63,18 @@ class DownloadIndexTest : BaseIntegrationTest() { } @Test - fun importIndexSucceeds() = runBlocking { + fun importIndexSucceeds() { val languagesBefore = catalogClient.library.getTargetLanguages() assertTrue("Languages before should not be empty", languagesBefore.isNotEmpty()) - val indexFile = directoryProvider.createTempFile("index", ".sqlite") + val indexFile = runBlocking { directoryProvider.createTempFile("index", ".sqlite") } directoryProvider.databaseFile.inputStream().use { input -> indexFile.outputStream().use { output -> input.copyTo(output) } } - val imported = downloadIndex.import(PlatformFile(indexFile)) + val imported = runBlocking { downloadIndex.import(PlatformFile(indexFile)) } assertTrue("Import result should be true", imported) diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/DownloadResourceContainersTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/DownloadResourceContainersTest.kt index 6fe9567..3ace65e 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/DownloadResourceContainersTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/DownloadResourceContainersTest.kt @@ -117,9 +117,9 @@ class DownloadResourceContainersTest : BaseIntegrationTest() { } @Test - fun downloadIncorrectResourceContainers() = runTest { + fun downloadIncorrectResourceContainers() { val badTranslationIds = listOf("bad_tr_id1", "bad_tr_id2") - val result = downloadResourceContainers.download(badTranslationIds) + val result = runBlocking { downloadResourceContainers.download(badTranslationIds) } assertNotNull("Download result should not be null", result) diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/GetRepositoryTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/GetRepositoryTest.kt index 964dd01..d908548 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/GetRepositoryTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/GetRepositoryTest.kt @@ -61,11 +61,11 @@ class GetRepositoryTest : BaseIntegrationTest() { } @Test - fun getRepositorySucceeds() = runBlocking { + fun getRepositorySucceeds() { loginGogsUser() processRepoResponse(targetTranslation.id) - val repo = getRepository.execute(targetTranslation) + val repo = runBlocking { getRepository.execute(targetTranslation) } assertNotNull("Repository should not be null", repo) @@ -73,29 +73,26 @@ class GetRepositoryTest : BaseIntegrationTest() { } @Test - fun getRepositoryThatIsNotExactNameFails() = runBlocking { + fun getRepositoryThatIsNotExactNameFails() { loginGogsUser() processRepoResponse("${targetTranslation.id}_L3") - val repo = getRepository.execute(targetTranslation) + val repo = runBlocking { getRepository.execute(targetTranslation) } assertNull("Repository should be null", repo) } @Test - fun getRepositoryNotAuthorizedFails() = runBlocking { - val repo = getRepository.execute(targetTranslation) + fun getRepositoryNotAuthorizedFails() { + val repo = runBlocking { getRepository.execute(targetTranslation) } assertNull("Repository should be null", repo) } - private fun loginGogsUser() = runBlocking { - profile.gogsUser = TestUtils.simulateLoginGogsUser( - platform, - server, - gogsLogin, - "test" - ) + private fun loginGogsUser() { + profile.gogsUser = runBlocking { + TestUtils.simulateLoginGogsUser(platform, server, gogsLogin, "test") + } } private fun processRepoResponse(id: String) { diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/GogsLoginLogoutTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/GogsLoginLogoutTest.kt index d1f532b..223a0e7 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/GogsLoginLogoutTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/GogsLoginLogoutTest.kt @@ -38,46 +38,45 @@ class GogsLoginLogoutTest : BaseIntegrationTest() { } @Test - fun testGogsLogin() = runBlocking { - val user = loginUserWithPassword("Test User") + fun testGogsLogin() { + val user = runBlocking { loginUserWithPassword("Test User") } assertEquals("Test User", user.fullName) } @Test - fun testGogsLoginWithoutFullName() = runBlocking { - val user = loginUserWithPassword() + fun testGogsLoginWithoutFullName() { + val user = runBlocking { loginUserWithPassword() } assertEquals("", user.fullName) } @Test - fun testGogsLoginWithWrongCredentials() = runBlocking { - val result = gogsLogin.execute( - "btt-test", - "incorrect_password" - ) + fun testGogsLoginWithWrongCredentials() { + val result = runBlocking { gogsLogin.execute("btt-test", "incorrect_password") } assertNull("User should be null", result.user) } @Test - fun testGogsLogout() = runBlocking { - val userBefore = loginUserWithPassword() - profile.gogsUser = userBefore + fun testGogsLogout() { + runBlocking { + val userBefore = loginUserWithPassword() + profile.gogsUser = userBefore - server.enqueue(MockResponse.Builder().code(204).build()) // delete token response + server.enqueue(MockResponse.Builder().code(204).build()) // delete token response - gogsLogout.execute() + gogsLogout.execute() - val userAfter = loginUserWithPassword() + val userAfter = loginUserWithPassword() - println(userBefore.token) - println(userAfter.token) + println(userBefore.token) + println(userAfter.token) - assertFalse( - "Token should be updated after logout", - userBefore.token == userAfter.token - ) - assertEquals("User should be the same", userBefore.username, userAfter.username) + assertFalse( + "Token should be updated after logout", + userBefore.token == userAfter.token + ) + assertEquals("User should be the same", userBefore.username, userAfter.username) + } } private suspend fun loginUserWithPassword(fullName: String? = null): User { diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/PullTargetTranslationTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/PullTargetTranslationTest.kt index faf1e01..a21fa88 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/PullTargetTranslationTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/PullTargetTranslationTest.kt @@ -75,7 +75,7 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationAuthorizedNewRepo() = runBlocking { + fun testPullTargetTranslationAuthorizedNewRepo() { loginGogsUser() var progressMessage: String? = null @@ -88,12 +88,14 @@ class PullTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(Exception("New repo doesn't have a branch yet.")) - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be UNKNOWN", @@ -105,7 +107,7 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationAuthorizedExistingRepo() = runBlocking { + fun testPullTargetTranslationAuthorizedExistingRepo() { loginGogsUser() var progressMessage: String? = null @@ -125,12 +127,14 @@ class PullTargetTranslationTest : BaseIntegrationTest() { every { pullResult.mergeResult }.returns(mergeResult) every { anyConstructed().call() }.returns(pullResult) - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be MERGE_CONFLICTS", @@ -142,7 +146,7 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationAuthorizedExistingRepoNoMergeConflicts() = runBlocking { + fun testPullTargetTranslationAuthorizedExistingRepoNoMergeConflicts() { loginGogsUser() var progressMessage: String? = null @@ -159,12 +163,14 @@ class PullTargetTranslationTest : BaseIntegrationTest() { every { pullResult.mergeResult }.returns(mergeResult) every { anyConstructed().call() }.returns(pullResult) - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be UP_TO_DATE", @@ -176,7 +182,7 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationAuthorizedConflicts() = runBlocking { + fun testPullTargetTranslationAuthorizedConflicts() { loginGogsUser() var progressMessage: String? = null @@ -197,12 +203,14 @@ class PullTargetTranslationTest : BaseIntegrationTest() { every { pullResult.mergeResult }.returns(mergeResult) every { anyConstructed().call() }.returns(pullResult) - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be MERGE_CONFLICTS", @@ -214,18 +222,20 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationUnAuthorizedFails() = runBlocking { + fun testPullTargetTranslationUnAuthorizedFails() { var progressMessage: String? = null val onProgress: (Float, String?) -> Unit = { _, message -> progressMessage = message } - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be AUTH_FAILURE", @@ -237,7 +247,7 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationAuthorizationFailed() = runBlocking { + fun testPullTargetTranslationAuthorizationFailed() { loginGogsUser() var progressMessage: String? = null @@ -256,12 +266,14 @@ class PullTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(exception) - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be AUTH_FAILURE", @@ -273,7 +285,7 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationRemoteNotFound() = runBlocking { + fun testPullTargetTranslationRemoteNotFound() { loginGogsUser() var progressMessage: String? = null @@ -290,12 +302,14 @@ class PullTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(exception) - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be NO_REMOTE_REPO", @@ -307,7 +321,7 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationUnknownTransportException() = runBlocking { + fun testPullTargetTranslationUnknownTransportException() { loginGogsUser() var progressMessage: String? = null @@ -320,12 +334,14 @@ class PullTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(TransportException("An error occurred.")) - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be UNKNOWN", @@ -337,7 +353,7 @@ class PullTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPullTargetTranslationOutOfMemoryError() = runBlocking { + fun testPullTargetTranslationOutOfMemoryError() { loginGogsUser() var progressMessage: String? = null @@ -350,12 +366,14 @@ class PullTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(OutOfMemoryError("Out of memory.")) - val result = pullTargetTranslation.execute( - targetTranslation, - MergeStrategy.RECURSIVE, - null, - onProgress - ) + val result = runBlocking { + pullTargetTranslation.execute( + targetTranslation, + MergeStrategy.RECURSIVE, + null, + onProgress + ) + } assertEquals( "Pull status should be OUT_OF_MEMORY", @@ -366,8 +384,10 @@ class PullTargetTranslationTest : BaseIntegrationTest() { assertNotNull("Progress message should not be null", progressMessage) } - private suspend fun loginGogsUser() { - val user = TestUtils.simulateLoginGogsUser(platform, server, gogsLogin, "test") + private fun loginGogsUser() { + val user = runBlocking { + TestUtils.simulateLoginGogsUser(platform, server, gogsLogin, "test") + } every { profile.gogsUser } returns user profile.gogsUser = user } diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/PushTargetTranslationTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/PushTargetTranslationTest.kt index 33c5f63..aec8e45 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/PushTargetTranslationTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/PushTargetTranslationTest.kt @@ -76,7 +76,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationAuthorized() = runBlocking { + fun testPushTargetTranslationAuthorized() { loginGogsUser() var progressMessage: String? = null @@ -94,7 +94,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { pushResult.remoteUpdates }.returns(listOf(refUpdate)) every { anyConstructed().call() }.returns(listOf(pushResult)) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail, because remote exists but not synced with local", @@ -107,7 +109,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationNotSynced() = runBlocking { + fun testPushTargetTranslationNotSynced() { loginGogsUser() var progressMessage: String? = null @@ -125,7 +127,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { pushResult.remoteUpdates }.returns(listOf(refUpdate)) every { anyConstructed().call() }.returns(listOf(pushResult)) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail, because remote exists but not synced with local", @@ -138,7 +142,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationRefDeleteNotAllowed() = runBlocking { + fun testPushTargetTranslationRefDeleteNotAllowed() { loginGogsUser() var progressMessage: String? = null @@ -156,7 +160,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { pushResult.remoteUpdates }.returns(listOf(refUpdate)) every { anyConstructed().call() }.returns(listOf(pushResult)) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail, because remote doesn't allow deleting refs", @@ -169,7 +175,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationRemoteChanged() = runBlocking { + fun testPushTargetTranslationRemoteChanged() { loginGogsUser() var progressMessage: String? = null @@ -187,7 +193,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { pushResult.remoteUpdates }.returns(listOf(refUpdate)) every { anyConstructed().call() }.returns(listOf(pushResult)) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail, because remote changed during push", @@ -200,7 +208,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationRejectedByOtherReason() = runBlocking { + fun testPushTargetTranslationRejectedByOtherReason() { loginGogsUser() var progressMessage: String? = null @@ -219,7 +227,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { pushResult.remoteUpdates }.returns(listOf(refUpdate)) every { anyConstructed().call() }.returns(listOf(pushResult)) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail for other reason", @@ -232,7 +242,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationNotRejected() = runBlocking { + fun testPushTargetTranslationNotRejected() { loginGogsUser() var progressMessage: String? = null @@ -251,7 +261,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { pushResult.remoteUpdates }.returns(listOf(refUpdate)) every { anyConstructed().call() }.returns(listOf(pushResult)) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail for other reason", @@ -264,13 +276,15 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationUnAuthorized() = runBlocking { + fun testPushTargetTranslationUnAuthorized() { var progressMessage: String? = null val onProgress: (Float, String?) -> Unit = { _, message -> progressMessage = message } - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Fails when there is no auth user", @@ -282,7 +296,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationAuthorizationFails() = runBlocking { + fun testPushTargetTranslationAuthorizationFails() { loginGogsUser() var progressMessage: String? = null @@ -301,7 +315,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(exception) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail", @@ -313,7 +329,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationToPrivateRepoFails() = runBlocking { + fun testPushTargetTranslationToPrivateRepoFails() { loginGogsUser() var progressMessage: String? = null @@ -330,7 +346,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(exception) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail", @@ -342,7 +360,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationNoRemoteException() = runBlocking { + fun testPushTargetTranslationNoRemoteException() { loginGogsUser() var progressMessage: String? = null @@ -359,7 +377,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(exception) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail", @@ -371,7 +391,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationUnknownTransportException() = runBlocking { + fun testPushTargetTranslationUnknownTransportException() { loginGogsUser() var progressMessage: String? = null @@ -388,7 +408,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(exception) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail", @@ -400,7 +422,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationOutOfMemoryError() = runBlocking { + fun testPushTargetTranslationOutOfMemoryError() { loginGogsUser() var progressMessage: String? = null @@ -413,7 +435,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(OutOfMemoryError("An error occurred.")) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail", @@ -425,7 +449,7 @@ class PushTargetTranslationTest : BaseIntegrationTest() { } @Test - fun testPushTargetTranslationGenericError() = runBlocking { + fun testPushTargetTranslationGenericError() { loginGogsUser() var progressMessage: String? = null @@ -438,7 +462,9 @@ class PushTargetTranslationTest : BaseIntegrationTest() { every { anyConstructed().call() } .throws(Exception("An error occurred.")) - val result = pushTargetTranslation.execute(targetTranslation, onProgress) + val result = runBlocking { + pushTargetTranslation.execute(targetTranslation, onProgress) + } assertEquals( "Push should fail", @@ -449,8 +475,10 @@ class PushTargetTranslationTest : BaseIntegrationTest() { assertNotNull("Progress message should not be null", progressMessage) } - private suspend fun loginGogsUser() { - val user = TestUtils.simulateLoginGogsUser(platform, server, gogsLogin, "test") + private fun loginGogsUser() { + val user = runBlocking { + TestUtils.simulateLoginGogsUser(platform, server, gogsLogin, "test") + } every { profile.gogsUser } returns user profile.gogsUser = user } diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/RegisterSSHKeysTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/RegisterSSHKeysTest.kt index 60ffe34..269b803 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/RegisterSSHKeysTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/RegisterSSHKeysTest.kt @@ -49,7 +49,7 @@ class RegisterSSHKeysTest : BaseIntegrationTest() { } @Test - fun testRegisterSSHKeys() = runBlocking { + fun testRegisterSSHKeys() { loginGogsUser() var progressMessage: String? = null @@ -60,7 +60,7 @@ class RegisterSSHKeysTest : BaseIntegrationTest() { val hasSSHKeys = directoryProvider.hasSSHKeys() assertFalse("SSH keys should not exist", hasSSHKeys) - val registered = registerSSHKeys.execute(false, onProgress) + val registered = runBlocking { registerSSHKeys.execute(false, onProgress) } assertTrue("SSH keys registered ", registered) assertNotNull("Progress message should not be null", progressMessage) @@ -70,7 +70,7 @@ class RegisterSSHKeysTest : BaseIntegrationTest() { } progressMessage = null - val registered2 = registerSSHKeys.execute(true, onProgress) + val registered2 = runBlocking { registerSSHKeys.execute(true, onProgress) } assertTrue("SSH keys registered with force flag", registered2) assertNotNull("Progress message should not be null", progressMessage) @@ -87,13 +87,13 @@ class RegisterSSHKeysTest : BaseIntegrationTest() { } @Test - fun testRegisterSSHKeys_noUser() = runBlocking { + fun testRegisterSSHKeys_noUser() { var progressMessage: String? = null val onProgress: (Float, String?) -> Unit = { _, message -> progressMessage = message } - val registered = registerSSHKeys.execute(false, onProgress) + val registered = runBlocking { registerSSHKeys.execute(false, onProgress) } assertFalse("SSH keys not registered without user", registered) assertNotNull("Progress message should not be null", progressMessage) diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/SearchGogsRepositoriesTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/SearchGogsRepositoriesTest.kt index d422047..4067583 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/SearchGogsRepositoriesTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/SearchGogsRepositoriesTest.kt @@ -31,7 +31,7 @@ class SearchGogsRepositoriesTest : BaseIntegrationTest() { } @Test - fun searchReposByUser() = runBlocking { + fun searchReposByUser() { val userResponse = """ { "data": [ @@ -52,7 +52,7 @@ class SearchGogsRepositoriesTest : BaseIntegrationTest() { .build()) val user = "test" - val gogsUser = searchGogsUsers.execute(user, 1).singleOrNull() + val gogsUser = runBlocking { searchGogsUsers.execute(user, 1).singleOrNull() } assertNotNull("Gogs user should not be null", gogsUser) assertEquals("Gogs user id should match", gogsUser?.id, 222) @@ -93,7 +93,7 @@ class SearchGogsRepositoriesTest : BaseIntegrationTest() { val onProgress: (Float, String?) -> Unit = { _, message -> progressMessage = message } - val repos = searchGogsRepositories.execute(gogsUser!!.id, "", 3, onProgress) + val repos = runBlocking { searchGogsRepositories.execute(gogsUser!!.id, "", 3, onProgress) } assertTrue("Repos should not be empty", repos.isNotEmpty()) assertFalse("Progress message should not be empty", progressMessage.isNullOrEmpty()) @@ -104,11 +104,11 @@ class SearchGogsRepositoriesTest : BaseIntegrationTest() { assertEquals("Repo cloneUrl should match","http://example.com/test_repo.git", repo.cloneUrl) assertEquals("Repo sshUrl should match","ssh://example.com/test_repo.git", repo.sshUrl) assertFalse("Repo should not be private", repo.isPrivate) - assertEquals("Repo owner should match", gogsUser.username, repo.owner?.username) + assertEquals("Repo owner should match", gogsUser!!.username, repo.owner?.username) } @Test - fun searchReposByRepoName() = runBlocking { + fun searchReposByRepoName() { val repo1Response = """ { "id": 111, @@ -161,7 +161,7 @@ class SearchGogsRepositoriesTest : BaseIntegrationTest() { .build()) val query = "_gen_" - val repos = searchGogsRepositories.execute(0, query, 3) + val repos = runBlocking { searchGogsRepositories.execute(0, query, 3) } assertTrue("Repos should not be empty", repos.isNotEmpty()) assertEquals("Repos size should match", 2, repos.size) @@ -170,7 +170,7 @@ class SearchGogsRepositoriesTest : BaseIntegrationTest() { } @Test - fun searchNonExistentRepo() = runBlocking { + fun searchNonExistentRepo() { val reposResponse = """ { "data": [], @@ -184,7 +184,7 @@ class SearchGogsRepositoriesTest : BaseIntegrationTest() { .build()) val query = "non-existent-repo" - val repos = searchGogsRepositories.execute(0, query, 3) + val repos = runBlocking { searchGogsRepositories.execute(0, query, 3) } assertTrue("Repos should be empty", repos.isEmpty()) } } diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/SearchGogsUsersTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/SearchGogsUsersTest.kt index 4fe9ce0..a1af4d4 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/SearchGogsUsersTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/SearchGogsUsersTest.kt @@ -27,7 +27,7 @@ class SearchGogsUsersTest : BaseIntegrationTest() { } @Test - fun searchParticularUser() = runBlocking { + fun searchParticularUser() { val user = "test" var progressMessage: String? = null val onProgress: (Float, String?) -> Unit = { _, message -> @@ -53,7 +53,7 @@ class SearchGogsUsersTest : BaseIntegrationTest() { .code(200) .build()) - val gogsUser = searchGogsUsers.execute(user, 1, onProgress).singleOrNull() + val gogsUser = runBlocking { searchGogsUsers.execute(user, 1, onProgress).singleOrNull() } assertNotNull("Gogs user should not be null", gogsUser) assertEquals("Ids should match", gogsUser?.id, 222) @@ -62,7 +62,7 @@ class SearchGogsUsersTest : BaseIntegrationTest() { } @Test - fun searchMultipleUsersByQuery() = runBlocking { + fun searchMultipleUsersByQuery() { val successResponse = """ { "data": [ @@ -89,7 +89,7 @@ class SearchGogsUsersTest : BaseIntegrationTest() { .build()) val userQuery = "test" - val gogsUsers = searchGogsUsers.execute(userQuery, 3) + val gogsUsers = runBlocking { searchGogsUsers.execute(userQuery, 3) } assertTrue("Gogs users should not be empty", gogsUsers.isNotEmpty()) @@ -98,7 +98,7 @@ class SearchGogsUsersTest : BaseIntegrationTest() { } @Test - fun searchNonExistentUsers() = runBlocking { + fun searchNonExistentUsers() { val successResponse = """ { "data": [], @@ -112,7 +112,7 @@ class SearchGogsUsersTest : BaseIntegrationTest() { .build()) val userQuery = "non-existent-user" - val gogsUsers = searchGogsUsers.execute(userQuery, 3) + val gogsUsers = runBlocking { searchGogsUsers.execute(userQuery, 3) } assertTrue("Gogs users should be empty", gogsUsers.isEmpty()) } } diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UpdateCatalogsTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UpdateCatalogsTest.kt index a3e7855..232fdd3 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UpdateCatalogsTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UpdateCatalogsTest.kt @@ -57,10 +57,11 @@ class UpdateCatalogsTest : BaseIntegrationTest() { } @Test - fun testUpdateCatalogs() = runBlocking { - prepareCatalogs() - - val result = updateCatalogs.execute(false) + fun testUpdateCatalogs() { + val result = runBlocking { + prepareCatalogs() + updateCatalogs.execute(false) + } assertTrue("Update catalogs should succeed", result.success) assertEquals("Added 2 languages", 2, result.addedCount) diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UpdateSourceTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UpdateSourceTest.kt index a180310..3981e58 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UpdateSourceTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UpdateSourceTest.kt @@ -77,13 +77,13 @@ class UpdateSourceTest : BaseIntegrationTest() { } @Test - fun testUpdateSource() = runBlocking { + fun testUpdateSource() { val url = server.url("/test") every { preference.getPref(Preference.KEY_PREF_MEDIA_SERVER, any(), String::class) } returns url.toString() - val result = updateSource.execute() + val result = runBlocking { updateSource.execute() } assertTrue("Update source succeeded", result.success) assertEquals("Added 1 source", 1, result.addedCount) diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UploadCrashReportTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UploadCrashReportTest.kt index 8bd814c..6548065 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UploadCrashReportTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UploadCrashReportTest.kt @@ -39,14 +39,14 @@ class UploadCrashReportTest : BaseIntegrationTest() { } @Test - fun testUploadCrashReport() = runBlocking { + fun testUploadCrashReport() { createStackTraces() server.enqueue(MockResponse.Builder().body("{success: true}").code(200).build()) val message = "Test crash report" val uploadCrashReport = UploadCrashReport(directoryProvider) - val reported = uploadCrashReport.execute(message, "") + val reported = runBlocking { uploadCrashReport.execute(message, "") } assertTrue("Upload success when response code 200", reported) @@ -63,27 +63,27 @@ class UploadCrashReportTest : BaseIntegrationTest() { } @Test - fun crashReportFailsWhenNoCrashes() = runBlocking { + fun crashReportFailsWhenNoCrashes() { deleteStackTraces() server.enqueue(MockResponse.Builder().body("{success: true}").code(200).build()) val message = "Test crash report" val uploadCrashReport = UploadCrashReport(directoryProvider) - val reported = uploadCrashReport.execute(message, "") + val reported = runBlocking { uploadCrashReport.execute(message, "") } assertFalse("Upload failed when no crash files", reported) } @Test - fun testUploadCrashServerDown() = runBlocking { + fun testUploadCrashServerDown() { createStackTraces() server.enqueue(MockResponse.Builder().code(500).build()) val message = "Test crash report" val uploadCrashReport = UploadCrashReport(directoryProvider) - val reported = uploadCrashReport.execute(message, "") + val reported = runBlocking { uploadCrashReport.execute(message, "") } assertFalse("Upload fails when response code 500", reported) } diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UploadFeedbackTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UploadFeedbackTest.kt index 85fcc63..15293b0 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UploadFeedbackTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/integration/usecases/UploadFeedbackTest.kt @@ -36,7 +36,7 @@ class UploadFeedbackTest : BaseIntegrationTest() { } @Test - fun testUploadFeedback() = runBlocking { + fun testUploadFeedback() { server.enqueue(MockResponse.Builder().body("{success: true}").code(200).build()) Logger.i("UploadFeedbackTest", "This is an info log.") @@ -50,7 +50,7 @@ class UploadFeedbackTest : BaseIntegrationTest() { val uploadFeedback = UploadFeedback(directoryProvider) val notes = "This is a test note" - val uploaded = uploadFeedback.execute(notes, "") + val uploaded = runBlocking { uploadFeedback.execute(notes, "") } assertTrue("Feedback should be uploaded", uploaded) @@ -71,7 +71,7 @@ class UploadFeedbackTest : BaseIntegrationTest() { } @Test - fun testUploadFailsOnServerDown() = runBlocking { + fun testUploadFailsOnServerDown() { server.enqueue(MockResponse.Builder().body("{success: true}").code(500).build()) Logger.i("UploadFeedbackTest", "This is an info log.") @@ -85,7 +85,7 @@ class UploadFeedbackTest : BaseIntegrationTest() { val uploadFeedback = UploadFeedback(directoryProvider) val notes = "This is a test note" - val uploaded = uploadFeedback.execute(notes, "") + val uploaded = runBlocking { uploadFeedback.execute(notes, "") } assertFalse("Upload should be failed", uploaded) assertTrue( diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/BaseComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/BaseComponentTest.kt new file mode 100644 index 0000000..5a8d18d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/BaseComponentTest.kt @@ -0,0 +1,75 @@ +package org.bibletranslationtools.writer.unit.ui + +import com.arkivanov.decompose.ComponentContext +import com.arkivanov.decompose.DecomposeSettings +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.decompose.ExperimentalDecomposeApi +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.destroy +import com.arkivanov.essenty.lifecycle.resume +import com.arkivanov.essenty.lifecycle.stop +import io.mockk.unmockkAll +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainCoroutineDispatcher +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.bibletranslationtools.writer.core.ComponentScope +import org.junit.After +import org.junit.Before +import org.koin.core.context.GlobalContext.stopKoin +import org.koin.test.KoinTest +import kotlin.coroutines.CoroutineContext + +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalDecomposeApi::class) +abstract class BaseComponentTest : KoinTest { + + private val trackedLifecycles = mutableListOf() + private val trackedScopes = mutableListOf() + + @Before + fun baseSetUp() { + Dispatchers.setMain(SafeUnconfinedDispatcher) + DecomposeSettings.update { it.copy(mainThreadCheckEnabled = false) } + } + + @After + fun baseTearDown() { + runBlocking { + trackedScopes.forEach { + runCatching { it.coroutineContext[Job]?.cancelAndJoin() } + } + } + trackedScopes.clear() + trackedLifecycles.forEach { + runCatching { it.stop() } + runCatching { it.destroy() } + } + trackedLifecycles.clear() + unmockkAll() + Dispatchers.resetMain() + DecomposeSettings.update { it.copy(mainThreadCheckEnabled = true) } + stopKoin() + } + + fun createComponent(factory: (ComponentContext) -> T): T { + val lifecycle = LifecycleRegistry() + trackedLifecycles.add(lifecycle) + val component = factory(DefaultComponentContext(lifecycle = lifecycle)) + lifecycle.resume() + if (component is ComponentScope) { + trackedScopes.add(component.coroutineScope) + } + return component + } +} + +internal object SafeUnconfinedDispatcher : MainCoroutineDispatcher() { + override val immediate: MainCoroutineDispatcher get() = this + override fun isDispatchNeeded(context: CoroutineContext): Boolean = false + override fun dispatch(context: CoroutineContext, block: Runnable) = block.run() +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/TestComponentContext.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/TestComponentContext.kt new file mode 100644 index 0000000..d6c7148 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/TestComponentContext.kt @@ -0,0 +1,18 @@ +package org.bibletranslationtools.writer.unit.ui + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout + +suspend fun StateFlow.awaitState(timeoutMs: Long = 1000, condition: (T) -> Boolean): T { + return withTimeout(timeoutMs) { + this@awaitState.first { condition(it) } + } +} + +suspend fun Flow.awaitEvent(timeoutMs: Long = 1000, condition: (T) -> Boolean = { true }): T { + return withTimeout(timeoutMs) { + this@awaitEvent.first { condition(it) } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/crash/CrashComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/crash/CrashComponentTest.kt new file mode 100644 index 0000000..e260d9e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/crash/CrashComponentTest.kt @@ -0,0 +1,153 @@ +package org.bibletranslationtools.writer.unit.ui.crash + +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.async +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.ui.crash.CrashComponent +import org.bibletranslationtools.writer.ui.crash.DefaultCrashComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitEvent +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.CheckForLatestRelease +import org.bibletranslationtools.writer.usecases.UploadCrashReport +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CrashComponentTest : BaseComponentTest() { + + private val checkForLatestRelease: CheckForLatestRelease = mockk() + private val uploadCrashReport: UploadCrashReport = mockk() + private val platform: Platform = mockk() + + private var resultReceived: CrashComponent.Result? = null + + @Before + fun setUpComponent() { + startKoin { + modules( + module { + single { checkForLatestRelease } + single { uploadCrashReport } + single { platform } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultCrashComponent = createComponent { context -> + DefaultCrashComponent( + componentContext = context, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitialState() { + val component = createComponent() + val state = component.state.value + + assertEquals("", state.notes) + assertEquals("", state.email) + assertEquals(false, state.success) + assertNull(state.release) + assertNull(component.progress.value) + } + + @Test + fun testSendCrashReportValidationFailure() = runBlocking { + val component = createComponent() + + val eventDeferred = async { + component.event.awaitEvent { it is CrashComponent.Event.SnackbarMessage } + } + + component.sendCrashReport(notes = "", email = "test@example.com") + + val event = eventDeferred.await() + assertTrue(event is CrashComponent.Event.SnackbarMessage) + } + + @Test + fun testSendCrashReportNewReleaseAvailable() = runBlocking { + val testRelease = CheckForLatestRelease.Release("v2.0", "http://download.url", 1024, 2) + coEvery { checkForLatestRelease.execute() } returns CheckForLatestRelease.Result(testRelease) + + val component = createComponent() + component.sendCrashReport(notes = "App crashed on startup", email = "test@example.com") + + component.state.awaitState { it.release == testRelease } + + val state = component.state.value + assertEquals("App crashed on startup", state.notes) + assertEquals("test@example.com", state.email) + assertEquals(testRelease, state.release) + assertEquals(false, state.success) + } + + @Test + fun testSendCrashReportSuccessNoNewRelease() = runBlocking { + coEvery { checkForLatestRelease.execute() } returns CheckForLatestRelease.Result(null) + coEvery { uploadCrashReport.execute("App crashed on startup", "test@example.com") } returns true + + val component = createComponent() + component.sendCrashReport(notes = "App crashed on startup", email = "test@example.com") + + component.state.awaitState { it.success } + + val state = component.state.value + assertEquals("App crashed on startup", state.notes) + assertEquals("test@example.com", state.email) + assertNull(state.release) + assertEquals(true, state.success) + } + + @Test + fun testSendCrashReportFailureNoNewRelease() = runBlocking { + coEvery { checkForLatestRelease.execute() } returns CheckForLatestRelease.Result(null) + coEvery { uploadCrashReport.execute(any(), any()) } returns false + + val component = createComponent() + + val eventDeferred = async { + component.event.awaitEvent { it is CrashComponent.Event.UploadError } + } + + component.sendCrashReport(notes = "App crashed on startup", email = "test@example.com") + + val event = eventDeferred.await() + assertTrue(event is CrashComponent.Event.UploadError) + + val state = component.state.value + assertEquals(false, state.success) + } + + @Test + fun testFlushAndRestart() { + val component = createComponent() + component.flushAndRestart() + + assertEquals(CrashComponent.Result.Restart, resultReceived) + } + + @Test + fun testClearLatestRelease() = runBlocking { + val testRelease = CheckForLatestRelease.Release("v2.0", "http://download.url", 1024, 2) + coEvery { checkForLatestRelease.execute() } returns CheckForLatestRelease.Result(testRelease) + + val component = createComponent() + component.sendCrashReport(notes = "Crash notes", email = "") + + component.state.awaitState { it.release != null } + + component.clearLatestRelease() + assertNull(component.state.value.release) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/devtools/DevToolsComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/devtools/DevToolsComponentTest.kt new file mode 100644 index 0000000..6a34273 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/devtools/DevToolsComponentTest.kt @@ -0,0 +1,178 @@ +package org.bibletranslationtools.writer.unit.ui.devtools + +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.logger.LogEntry +import org.bibletranslationtools.logger.LogLevel +import org.bibletranslationtools.logger.Logger +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.writer.DirectoryProvider +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.ui.devtools.DefaultDevToolsComponent +import org.bibletranslationtools.writer.ui.devtools.DevToolsComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import java.util.Date +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +class DevToolsComponentTest : BaseComponentTest() { + + private val directoryProvider: DirectoryProvider = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + private val preference: Preference = mockk(relaxed = true) + + private var resultReceived: DevToolsComponent.Result? = null + + @Before + fun setUpComponent() { + mockkObject(Logger) + mockkObject(LogLevel.Companion) + + every { platform.info.versionName } returns "1.0.0" + every { platform.info.versionCode } returns 10 + every { platform.udid } returns "test-device-id" + every { platform.calculateSystemResources() } returns "CPU: 4, RAM: 8GB" + every { preference.getPref(any(), any(), any()) } answers { args[1]!! } + + startKoin { + modules( + module { + single { directoryProvider } + single { catalogClient } + single { platform } + single { preference } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultDevToolsComponent = createComponent { context -> + DefaultDevToolsComponent( + componentContext = context, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitialProperties() { + val component = createComponent() + assertEquals("1.0.0", component.versionName) + assertEquals(10, component.versionCode) + assertEquals("test-device-id", component.udid) + assertEquals("CPU: 4, RAM: 8GB", component.calculateSystemResources()) + } + + @Test + fun testLoadTools() { + runBlocking { + val component = createComponent() + assertEquals(0, component.state.value.tools.size) + + component.loadTools() + + delayYield() + + assertEquals(5, component.state.value.tools.size) + assertEquals("Regenerate SSH keys", component.state.value.tools[0].name) + assertEquals("Read debugging log", component.state.value.tools[1].name) + } + } + + @Test + fun testReadErrorLog() { + runBlocking { + val logEntries = listOf( + LogEntry(Date(), LogLevel.Info, "test tag", "info message", ""), + LogEntry(Date(), LogLevel.Warning, "test tag", "warning message", "") + ) + every { Logger.getLogEntries() } returns logEntries + every { preference.getPref(Preference.KEY_PREF_LOGGING_LEVEL, LogLevel.Info.name, String::class) } returns LogLevel.Warning.name + every { LogLevel.getLevel(any()) } returns LogLevel.Warning + + val component = createComponent() + component.readErrorLog() + + delayYield() + + val logsInState = component.state.value.logs + assertEquals(1, logsInState.size) + assertEquals("warning message", logsInState.first().message) + } + } + + @Test + fun testNavigateBack() { + val component = createComponent() + component.navigateBack() + assertEquals(DevToolsComponent.Result.NavigateBack, resultReceived) + } + + @Test + fun testSimulateCrashThrowsException() { + runBlocking { + val component = createComponent() + component.loadTools() + delayYield() + + val simulateCrashItem = component.state.value.tools.first { it.name == "Simulate crash" } + assertFailsWith { + simulateCrashItem.action() + } + } + } + + @Test + fun testClearKeysRegenerated() { + runBlocking { + val component = createComponent() + component.loadTools() + delayYield() + + val regenerateItem = component.state.value.tools.first { it.name == "Regenerate SSH keys" } + regenerateItem.action() + + delayYield() + assertEquals(true, component.state.value.keysRegenerated) + + component.clearKeysRegenerated() + assertNull(component.state.value.keysRegenerated) + + coVerify { directoryProvider.generateSSHKeys("test-device-id") } + } + } + + @Test + fun testDeleteLibrary() { + runBlocking { + val component = createComponent() + component.loadTools() + delayYield() + + val deleteLibraryItem = component.state.value.tools.first { it.name == "Delete Library" } + deleteLibraryItem.action() + + delayYield() + + coVerify { catalogClient.closeLibrary() } + coVerify { directoryProvider.deleteLibrary() } + coVerify { directoryProvider.deployDefaultLibrary() } + coVerify { catalogClient.openLibrary() } + } + } + + private suspend fun delayYield() { + delay(50) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/download/DownloadSourcesComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/download/DownloadSourcesComponentTest.kt new file mode 100644 index 0000000..083a1de --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/download/DownloadSourcesComponentTest.kt @@ -0,0 +1,93 @@ +package org.bibletranslationtools.writer.unit.ui.dialogs.download + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.library.models.Translation +import org.bibletranslationtools.resourcecontainer.Language +import org.bibletranslationtools.writer.ui.dialogs.download.DefaultDownloadSourcesComponent +import org.bibletranslationtools.writer.ui.dialogs.download.DownloadListItem +import org.bibletranslationtools.writer.ui.dialogs.download.FilterMode +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.DownloadResourceContainers +import org.bibletranslationtools.writer.usecases.GetAvailableSources +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DownloadSourcesComponentTest : BaseComponentTest() { + + private val getAvailableSources: GetAvailableSources = mockk(relaxed = true) + private val downloadResourceContainers: DownloadResourceContainers = mockk(relaxed = true) + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(any()) } returns "Mock String" + + startKoin { + modules( + module { + single { getAvailableSources } + single { downloadResourceContainers } + } + ) + } + } + + private fun createComponent(): DefaultDownloadSourcesComponent = + createComponent { context -> + DefaultDownloadSourcesComponent( + componentContext = context + ) + } + + @Test + fun testInitializationLoadsSources() { + runBlocking { + val mockLanguage = mockk(relaxed = true) { + every { name } returns "English" + every { slug } returns "en" + } + val mockTranslation = mockk(relaxed = true) { + every { language } returns mockLanguage + } + val mockResult = GetAvailableSources.Result( + sources = listOf(mockTranslation), + byLanguage = mapOf("en" to listOf(0)), + otBooks = emptyMap(), + ntBooks = emptyMap(), + otherBooks = emptyMap() + ) + coEvery { getAvailableSources.execute(any()) } returns mockResult + + val component = createComponent() + component.state.awaitState { it.listItems.isNotEmpty() } + + // Verify filter category contains the English language item + val listItems = component.state.value.listItems + assertTrue(listItems.isNotEmpty()) + val firstCategory = listItems.first() as DownloadListItem.FilterCategory + assertEquals("en", firstCategory.id) + assertEquals("English (en)", firstCategory.title) + } + } + + @Test + fun testFilterModeChange() { + runBlocking { + val component = createComponent() + component.onFilterModeChanged(FilterMode.ByBook) + component.state.awaitState { it.filterMode == FilterMode.ByBook } + + assertEquals(FilterMode.ByBook, component.state.value.filterMode) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/export/ExportComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/export/ExportComponentTest.kt new file mode 100644 index 0000000..cab2cc4 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/export/ExportComponentTest.kt @@ -0,0 +1,448 @@ +package org.bibletranslationtools.writer.unit.ui.dialogs.export + +import io.github.vinceglb.filekit.PlatformFile +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecontainer.Project +import org.bibletranslationtools.writer.DirectoryProvider +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.MergeConflictsHandler +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.displayName +import org.bibletranslationtools.writer.ui.dialogs.export.DefaultExportComponent +import org.bibletranslationtools.writer.ui.dialogs.export.ExportComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitEvent +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.CreateRepository +import org.bibletranslationtools.writer.usecases.DownloadImages +import org.bibletranslationtools.writer.usecases.ExportProjects +import org.bibletranslationtools.writer.usecases.GogsLogout +import org.bibletranslationtools.writer.usecases.PullTargetTranslation +import org.bibletranslationtools.writer.usecases.PushTargetTranslation +import org.bibletranslationtools.writer.usecases.RegisterSSHKeys +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ExportComponentTest : BaseComponentTest() { + + private val export: ExportProjects = mockk(relaxed = true) + private val downloadImages: DownloadImages = mockk(relaxed = true) + private val translator: Translator = mockk(relaxed = true) + private val profile: Profile = mockk(relaxed = true) + private val directoryProvider: DirectoryProvider = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val gogsLogout: GogsLogout = mockk(relaxed = true) + private val createRepository: CreateRepository = mockk(relaxed = true) + private val pullTargetTranslation: PullTargetTranslation = mockk(relaxed = true) + private val pushTargetTranslation: PushTargetTranslation = mockk(relaxed = true) + private val registerSSHKeys: RegisterSSHKeys = mockk(relaxed = true) + private val preference: Preference = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + private var resultReceived: ExportComponent.Result? = null + + private val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { targetLanguageName } returns "English" + every { projectId } returns "gen" + } + + private val mockProject = mockk(relaxed = true) { + every { name } returns "Genesis" + } + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + mockkStatic(PlatformFile::displayName) + coEvery { getString(any()) } returns "Mock String" + coEvery { getString(any(), *anyVararg()) } returns "Mock String" + + every { preference.getPref(any(), any(), any()) } answers { args[1]!! } + + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + every { catalogClient.library.getProject(any(), any(), any()) } returns mockProject + every { platform.deviceLanguageCode } returns "en" + + // Default push result: OK + coEvery { pushTargetTranslation.execute(any(), any()) } returns + PushTargetTranslation.Result(PushTargetTranslation.Status.OK, null) + + startKoin { + modules( + module { + single { export } + single { downloadImages } + single { translator } + single { profile } + single { directoryProvider } + single { catalogClient } + single { gogsLogout } + single { createRepository } + single { pullTargetTranslation } + single { pushTargetTranslation } + single { registerSSHKeys } + single { preference } + single { platform } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultExportComponent = + createComponent { context -> + DefaultExportComponent( + componentContext = context, + translationId = "target-1", + showPrint = true, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitialization() { + val component = createComponent() + assertNotNull(component.targetTranslation) + assertEquals("Genesis", component.projectName) + } + + @Test + fun testOnMergeConflict() { + val component = createComponent() + component.onMergeConflict() + assertTrue(resultReceived is ExportComponent.Result.MergeConflict) + assertEquals("target-1", (resultReceived as ExportComponent.Result.MergeConflict).translationId) + } + + @Test + fun testShowFeedbackDialog() { + val component = createComponent() + component.showFeedbackDialog("Test feedback") + assertTrue(resultReceived is ExportComponent.Result.OpenFeedback) + assertEquals("Test feedback", (resultReceived as ExportComponent.Result.OpenFeedback).message) + } + + @Test + fun testTranslationNotFoundReturnsError() { + runBlocking { + coEvery { translator.getTargetTranslation("missing-id") } returns null + + createComponent { context -> + DefaultExportComponent( + componentContext = context, + translationId = "missing-id", + showPrint = false, + onResult = { resultReceived = it } + ) + } + + assertTrue(resultReceived is ExportComponent.Result.Error) + } + } + + @Test + fun testClearStateMethods() { + runBlocking { + val component = createComponent() + + // All four clear methods should set their respective state field to null + component.clearInfoMessage() + assertNull(component.state.value.info) + + component.clearErrorMessage() + assertNull(component.state.value.uploadError) + + component.clearUploadSuccess() + assertNull(component.state.value.uploadSuccess) + + component.clearMergeConflict() + assertNull(component.state.value.mergeConflict) + } + } + + @Test + fun testLogoutThenLogin() { + runBlocking { + val component = createComponent() + component.logout(thenLogin = true) + + delay(300) + + coVerify { gogsLogout.execute() } + verify { profile.logout() } + assertTrue(resultReceived is ExportComponent.Result.OpenLogin) + } + } + + @Test + fun testLogoutWithoutLogin() { + runBlocking { + val component = createComponent() + component.logout(thenLogin = false) + + delay(300) + + coVerify { gogsLogout.execute() } + verify { profile.logout() } + assertTrue(resultReceived is ExportComponent.Result.Logout) + } + } + + @Test + fun testExportUsfmSuccess() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "genesis.usfm" + } + + val mockResult = mockk(relaxed = true) { + every { success } returns true + every { file } returns mockFile + } + coEvery { export.exportUSFM(any(), any()) } returns mockResult + + val component = createComponent() + component.exportUsfm(mockFile) + + component.state.awaitState { it.info != null } + assertNotNull(component.state.value.info) + } + } + + @Test + fun testExportUsfmWrongExtensionShowsError() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "genesis.txt" + } + val component = createComponent() + component.exportUsfm(mockFile) + + component.state.awaitState { it.info != null } + assertNotNull(component.state.value.info) + } + } + + @Test + fun testExportProjectSuccess() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "genesis.tstudio" + } + + val mockResult = mockk(relaxed = true) { + every { success } returns true + every { file } returns mockFile + } + coEvery { export.exportProject(any(), any(), any()) } returns mockResult + + val component = createComponent() + component.exportProject(mockFile) + + component.state.awaitState { it.info != null } + assertNotNull(component.state.value.info) + } + } + + @Test + fun testExportProjectWrongExtensionShowsError() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "genesis.badext" + } + val component = createComponent() + component.exportProject(mockFile) + + component.state.awaitState { it.info != null } + assertNotNull(component.state.value.info) + } + } + + @Test + fun testExportToAppFailureEmitsSnackbar() { + runBlocking { + val sharingDir = mockk(relaxed = true) + every { directoryProvider.sharingDir } returns sharingDir + + // Throw so the file is never created → snackbar + coEvery { export.exportProject(any(), any()) } throws Exception("Export error") + + val component = createComponent() + component.exportToApp() + + val event = component.event.awaitEvent(timeoutMs = 2000) + assertTrue(event is ExportComponent.Event.SnackbarMessage) + } + } + + @Test + fun testOpenExportToCloudPullUpToDateThenPushOk() { + runBlocking { + coEvery { pullTargetTranslation.execute(any(), any(), any(), any()) } returns + PullTargetTranslation.Result(PullTargetTranslation.Status.UP_TO_DATE, null) + + coEvery { pushTargetTranslation.execute(any(), any()) } returns + PushTargetTranslation.Result(PushTargetTranslation.Status.OK, "http://example.com/repo") + + every { profile.gogsUser } returns mockk(relaxed = true) { + every { username } returns "testuser" + } + + val component = createComponent() + component.openExportToCloud() + + component.state.awaitState(timeoutMs = 3000) { it.uploadSuccess != null } + assertNotNull(component.state.value.uploadSuccess) + } + } + + @Test + fun testOpenExportToCloudPullAuthFailureNoKeys() { + runBlocking { + coEvery { pullTargetTranslation.execute(any(), any(), any(), any()) } returns + PullTargetTranslation.Result(PullTargetTranslation.Status.AUTH_FAILURE, "Auth failed") + + every { directoryProvider.hasSSHKeys() } returns false + // registerSSHKeys returns false → emit AuthRequested + coEvery { registerSSHKeys.execute(any(), any()) } returns false + + val component = createComponent() + component.openExportToCloud() + + val event = component.event.awaitEvent(timeoutMs = 3000) + assertTrue(event is ExportComponent.Event.AuthRequested) + } + } + + @Test + fun testOpenExportToCloudPullAuthFailureWithKeys() { + runBlocking { + coEvery { pullTargetTranslation.execute(any(), any(), any(), any()) } returns + PullTargetTranslation.Result(PullTargetTranslation.Status.AUTH_FAILURE, "Auth failed") + + every { directoryProvider.hasSSHKeys() } returns true + + val component = createComponent() + component.openExportToCloud() + + val event = component.event.awaitEvent(timeoutMs = 3000) + assertTrue(event is ExportComponent.Event.AuthRequested) + } + } + + @Test + fun testOpenExportToCloudMergeConflictSetsMergeConflictState() { + runBlocking { + coEvery { pullTargetTranslation.execute(any(), any(), any(), any()) } returns + PullTargetTranslation.Result(PullTargetTranslation.Status.MERGE_CONFLICTS, null) + + mockkObject(MergeConflictsHandler) + coEvery { MergeConflictsHandler.isTranslationMergeConflicted(any(), any()) } returns true + + val component = createComponent() + component.openExportToCloud() + + component.state.awaitState(timeoutMs = 3000) { it.mergeConflict != null } + assertNotNull(component.state.value.mergeConflict) + } + } + + @Test + fun testRegisterKeysFails_EmitsAuthRequested() { + runBlocking { + coEvery { registerSSHKeys.execute(any(), any()) } returns false + + val component = createComponent() + component.registerKeys() + + val event = component.event.awaitEvent(timeoutMs = 3000) + assertTrue(event is ExportComponent.Event.AuthRequested) + } + } + + @Test + fun testRegisterKeysSuccessTriggersCloudExport() { + runBlocking { + coEvery { registerSSHKeys.execute(any(), any()) } returns true + + coEvery { pullTargetTranslation.execute(any(), any(), any(), any()) } returns + PullTargetTranslation.Result(PullTargetTranslation.Status.UP_TO_DATE, null) + + every { profile.gogsUser } returns mockk(relaxed = true) { + every { username } returns "testuser" + } + + val component = createComponent() + component.registerKeys() + + component.state.awaitState(timeoutMs = 3000) { it.uploadSuccess != null } + assertNotNull(component.state.value.uploadSuccess) + } + } + + @Test + fun testResetToMasterCallsTargetTranslation() { + runBlocking { + val component = createComponent() + component.resetToMaster() + + delay(300) + verify { mockTarget.resetToMasterBackup() } + } + } + + @Test + fun testPushRejectedSetsMergeConflictState() { + runBlocking { + coEvery { pullTargetTranslation.execute(any(), any(), any(), any()) } returns + PullTargetTranslation.Result(PullTargetTranslation.Status.UP_TO_DATE, null) + + coEvery { pushTargetTranslation.execute(any(), any()) } returns + PushTargetTranslation.Result(PushTargetTranslation.Status.REJECTED_NON_FAST_FORWARD, null) + + val component = createComponent() + component.openExportToCloud() + + component.state.awaitState(timeoutMs = 3000) { it.mergeConflict != null } + assertNotNull(component.state.value.mergeConflict) + } + } + + @Test + fun testPushAuthFailureEmitsAuthEvent() { + runBlocking { + coEvery { pullTargetTranslation.execute(any(), any(), any(), any()) } returns + PullTargetTranslation.Result(PullTargetTranslation.Status.UP_TO_DATE, null) + + coEvery { pushTargetTranslation.execute(any(), any()) } returns + PushTargetTranslation.Result(PushTargetTranslation.Status.AUTH_FAILURE, null) + + val component = createComponent() + component.openExportToCloud() + + val event = component.event.awaitEvent(timeoutMs = 3000) + assertTrue(event is ExportComponent.Event.AuthRequested) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/feedback/FeedbackComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/feedback/FeedbackComponentTest.kt new file mode 100644 index 0000000..b7d5887 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/feedback/FeedbackComponentTest.kt @@ -0,0 +1,84 @@ +package org.bibletranslationtools.writer.unit.ui.dialogs.feedback + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.ui.dialogs.feedback.DefaultFeedbackComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.usecases.CheckForLatestRelease +import org.bibletranslationtools.writer.usecases.UploadFeedback +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeedbackComponentTest : BaseComponentTest() { + + private val checkForLatestRelease: CheckForLatestRelease = mockk(relaxed = true) + private val uploadFeedback: UploadFeedback = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(any()) } returns "Mock String" + + startKoin { + modules( + module { + single { checkForLatestRelease } + single { uploadFeedback } + single { platform } + } + ) + } + } + + private fun createComponent(): DefaultFeedbackComponent = + createComponent { context -> + DefaultFeedbackComponent( + componentContext = context, + initialMessage = "Initial Error Message" + ) + } + + @Test + fun testInitialization() { + val component = createComponent() + assertEquals("Initial Error Message", component.initialMessage) + } + + @Test + fun testReportBug() { + runBlocking { + val mockCheckResult = CheckForLatestRelease.Result(release = null) + coEvery { checkForLatestRelease.execute() } returns mockCheckResult + coEvery { uploadFeedback.execute(any(), any()) } returns true + + val component = createComponent() + component.reportBug("Notes from user", "user@example.com") + + // Wait for coroutines running on Dispatchers.IO to finish + delay(150) + + coVerify { checkForLatestRelease.execute() } + coVerify { uploadFeedback.execute("Notes from user", "user@example.com") } + assertTrue(component.state.value.success) + } + } + + @Test + fun testClearError() { + val component = createComponent() + component.clearError() + assertNull(component.state.value.uploadError) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/import/ImportComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/import/ImportComponentTest.kt new file mode 100644 index 0000000..3991b36 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/import/ImportComponentTest.kt @@ -0,0 +1,662 @@ +package org.bibletranslationtools.writer.unit.ui.dialogs.import + +import io.github.vinceglb.filekit.PlatformFile +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.gogsclient.Repository +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecontainer.Project +import org.bibletranslationtools.writer.DirectoryProvider +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.TargetTranslationMigrator +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.displayName +import org.bibletranslationtools.writer.ui.dialogs.import.DefaultImportComponent +import org.bibletranslationtools.writer.ui.dialogs.import.ImportComponent +import org.bibletranslationtools.writer.ui.home.RepositoryItem +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitEvent +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.AdvancedGogsRepoSearch +import org.bibletranslationtools.writer.usecases.CloneRepository +import org.bibletranslationtools.writer.usecases.ImportProjects +import org.bibletranslationtools.writer.usecases.RegisterSSHKeys +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ImportComponentTest : BaseComponentTest() { + + private val translator: Translator = mockk(relaxed = true) + private val advancedGogsRepoSearch: AdvancedGogsRepoSearch = mockk(relaxed = true) + private val cloneRepository: CloneRepository = mockk(relaxed = true) + private val registerSSHKeys: RegisterSSHKeys = mockk(relaxed = true) + private val importProjects: ImportProjects = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val directoryProvider: DirectoryProvider = mockk(relaxed = true) + private val targetTranslationMigrator: TargetTranslationMigrator = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + private var resultReceived: ImportComponent.Result? = null + + private val mockBackupsDir = mockk(relaxed = true) { + every { listFiles() } returns emptyArray() + } + + private val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + } + + private val mockProject = mockk(relaxed = true) { + every { name } returns "Genesis" + } + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + mockkStatic(PlatformFile::displayName) + coEvery { getString(any()) } returns "Mock String" + coEvery { getString(any(), *anyVararg()) } returns "Mock String" + + every { directoryProvider.backupsDir } returns mockBackupsDir + coEvery { translator.getTargetTranslation(any()) } returns mockTarget + every { catalogClient.library.getProject(any(), any(), any()) } returns mockProject + + startKoin { + modules( + module { + single { translator } + single { advancedGogsRepoSearch } + single { cloneRepository } + single { registerSSHKeys } + single { importProjects } + single { catalogClient } + single { directoryProvider } + single { targetTranslationMigrator } + single { platform } + } + ) + } + resultReceived = null + } + + private fun createComponent(projectFile: PlatformFile? = null): DefaultImportComponent = + createComponent { context -> + DefaultImportComponent( + componentContext = context, + projectFile = projectFile, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitialization() { + val component = createComponent() + assertNotNull(component) + assertTrue(component.state.value.backups.isEmpty()) + } + + @Test + fun testClearMergeConflict() { + val component = createComponent() + component.clearMergeConflict() + assertNull(component.state.value.mergeConflict) + } + + @Test + fun testClearMethods() { + val component = createComponent() + component.clearResult() + assertNull(component.state.value.resultMessage) + assertTrue(component.state.value.repositories.isEmpty()) + + component.clearSourceConflict() + assertNull(component.state.value.sourceConflict) + + component.clearImportRepo() + assertNull(component.state.value.repoToImport) + } + + @Test + fun testImportUsfm() { + val component = createComponent() + val mockFile = mockk(relaxed = true) + component.importUsfm(mockFile) + assertTrue(resultReceived is ImportComponent.Result.OpenUsfmImport) + assertEquals(mockFile, (resultReceived as ImportComponent.Result.OpenUsfmImport).file) + } + + @Test + fun testImportSourceSuccess() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "source_dir" + } + coEvery { importProjects.importSource(mockFile, any()) } returns + ImportProjects.ImportSourceResult(success = true, hasConflict = false, file = mockFile) + + val component = createComponent() + component.importSource(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.resultMessage != null } + assertNotNull(component.state.value.resultMessage) + assertEquals("Mock String", component.state.value.resultMessage?.first) + } + } + + @Test + fun testImportSourceConflict() { + runBlocking { + val mockFile = mockk(relaxed = true) + val conflictResult = ImportProjects.ImportSourceResult(success = false, hasConflict = true, file = mockFile) + coEvery { importProjects.importSource(mockFile, any()) } returns conflictResult + + val component = createComponent() + component.importSource(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.sourceConflict != null } + assertEquals(conflictResult, component.state.value.sourceConflict) + } + } + + @Test + fun testImportSourceError() { + runBlocking { + val mockFile = mockk(relaxed = true) + coEvery { importProjects.importSource(mockFile, any()) } returns + ImportProjects.ImportSourceResult(success = false, hasConflict = false, error = "Bad source format") + + val component = createComponent() + component.importSource(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.resultMessage != null } + assertEquals("Bad source format", component.state.value.resultMessage?.second) + } + } + + @Test + fun testImportBackup() { + runBlocking { + val mockBackupFile = mockk(relaxed = true) { + every { name } returns "my_backup.tstudio" + } + every { any().displayName } returns "my_backup.tstudio" + + coEvery { importProjects.importProject(any(), any(), any()) } returns + ImportProjects.ImportPlatformFileResult( + file = mockk(relaxed = true) { every { displayName } returns "my_backup.tstudio" }, + importedSlug = "target-1", + success = true, + hasMergeConflict = false, + invalidFileName = false, + alreadyExists = false + ) + + val component = createComponent() + component.importBackup(mockBackupFile) + + component.state.awaitState(timeoutMs = 3000) { it.resultMessage != null } + assertTrue(resultReceived is ImportComponent.Result.ProjectsImported) + assertEquals("target-1", (resultReceived as ImportComponent.Result.ProjectsImported).translationIds.first()) + } + } + + @Test + fun testImportProjectSuccess() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "project.tstudio" + } + coEvery { importProjects.importProject(mockFile, any(), any()) } returns + ImportProjects.ImportPlatformFileResult( + file = mockFile, + importedSlug = "imported-slug", + success = true, + hasMergeConflict = false, + invalidFileName = false, + alreadyExists = false + ) + + val component = createComponent() + component.importProject(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.resultMessage != null } + assertTrue(resultReceived is ImportComponent.Result.ProjectsImported) + assertEquals("imported-slug", (resultReceived as ImportComponent.Result.ProjectsImported).translationIds.first()) + } + } + + @Test + fun testImportProjectInvalidFileName() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "project.zip" + } + coEvery { importProjects.importProject(mockFile, any(), any()) } returns + ImportProjects.ImportPlatformFileResult( + file = mockFile, + importedSlug = null, + success = false, + hasMergeConflict = false, + invalidFileName = true, + alreadyExists = false + ) + + val component = createComponent() + component.importProject(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.resultMessage != null } + assertNotNull(component.state.value.resultMessage) + } + } + + @Test + fun testImportProjectInvalidExtension() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "project.txt" + } + + val component = createComponent() + component.importProject(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.resultMessage != null } + assertNotNull(component.state.value.resultMessage) + coVerify(exactly = 0) { importProjects.importProject(any(), any(), any()) } + } + } + + @Test + fun testImportProjectAlreadyExistsMerge() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "project.tstudio" + } + coEvery { importProjects.importProject(mockFile, any(), any()) } returns + ImportProjects.ImportPlatformFileResult( + file = mockFile, + importedSlug = "target-1", + success = true, + hasMergeConflict = true, + invalidFileName = false, + alreadyExists = true + ) + + val component = createComponent() + component.importProject(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.mergeConflict != null } + val conflict = component.state.value.mergeConflict + assertNotNull(conflict) + assertEquals(mockTarget, conflict.translation) + assertTrue(conflict.hasMergeConflict) + } + } + + @Test + fun testImportRepoNotSupportedRepo() { + runBlocking { + val repoItem = RepositoryItem( + languageName = "English", + projectName = "Genesis", + targetTranslationSlug = "en_gen_text", + languageCode = "en", + languageDirection = "ltr", + repoName = "user/en_gen_text", + url = "http://repo.url", + isPrivate = false, + unsupportedTag = "Unsupported Tag" + ) + + val component = createComponent() + component.importRepo(repoItem, accepted = false, overwrite = false) + + component.state.awaitState(timeoutMs = 3000) { it.repoToImport != null } + assertEquals(repoItem, component.state.value.repoToImport) + } + } + + @Test + fun testSearchRepositoriesSuccess() { + runBlocking { + val repo = Repository(name = "en_gen_text", fullName = "user/en_gen_text") + coEvery { advancedGogsRepoSearch.execute(any(), any(), any(), any()) } returns listOf(repo) + + val component = createComponent() + component.searchRepositories("user", "gen") + + component.state.awaitState(timeoutMs = 3000) { it.repositories.isNotEmpty() } + assertEquals(1, component.state.value.repositories.size) + assertEquals("en_gen_text", component.state.value.repositories.first().targetTranslationSlug) + } + } + + @Test + fun testSearchRepositoriesException() { + runBlocking { + coEvery { advancedGogsRepoSearch.execute(any(), any(), any(), any()) } throws Exception("Search failed") + + val component = createComponent() + component.searchRepositories("user", "gen") + + component.state.awaitState(timeoutMs = 3000) { it.resultMessage != null } + assertTrue(component.state.value.repositories.isEmpty()) + assertNotNull(component.state.value.resultMessage) + } + } + + @Test + fun testForceRegisterKeysSuccess() { + runBlocking { + val repoItem = RepositoryItem( + languageName = "English", + projectName = "Genesis", + targetTranslationSlug = "en_gen_text", + languageCode = "en", + languageDirection = "ltr", + repoName = "user/en_gen_text", + url = "http://repo.url", + isPrivate = false, + unsupportedTag = "" + ) + coEvery { registerSSHKeys.execute(true, any()) } returns true + coEvery { cloneRepository.execute(any(), any()) } returns + CloneRepository.Result(CloneRepository.Status.SUCCESS, "http://repo.url", null) + + val component = createComponent() + val unsupportedRepoItem = repoItem.copy(unsupportedTag = "Unsupported") + component.importRepo(unsupportedRepoItem, accepted = false, overwrite = false) + component.state.awaitState(timeoutMs = 3000) { it.repoToImport != null } + + component.registerKeys() + + delay(300) + coVerify { registerSSHKeys.execute(true, any()) } + coVerify { cloneRepository.execute(repoItem.url, any()) } + } + } + + @Test + fun testForceRegisterKeysFailure() { + runBlocking { + coEvery { registerSSHKeys.execute(true, any()) } returns false + + val component = createComponent() + component.registerKeys() + + val event = component.event.awaitEvent(timeoutMs = 3000) + assertTrue(event is ImportComponent.Event.AuthRequested) + } + } + + @Test + fun testCloneRepositorySuccess() { + runBlocking { + val repoItem = RepositoryItem( + languageName = "English", + projectName = "Genesis", + targetTranslationSlug = "en_gen_text", + languageCode = "en", + languageDirection = "ltr", + repoName = "user/en_gen_text", + url = "http://repo.url", + isPrivate = false, + unsupportedTag = "" + ) + + val mockTempDir = File.createTempFile("temp_dir", "") + mockTempDir.delete() + mockTempDir.mkdirs() + mockTempDir.deleteOnExit() + + coEvery { cloneRepository.execute(repoItem.url, any()) } returns + CloneRepository.Result(CloneRepository.Status.SUCCESS, repoItem.url, mockTempDir) + + val mockMigratedDir = File.createTempFile("migrated_dir", "") + mockMigratedDir.delete() + mockMigratedDir.mkdirs() + mockMigratedDir.deleteOnExit() + + coEvery { targetTranslationMigrator.migrate(mockTempDir, any()) } returns mockMigratedDir + coEvery { targetTranslationMigrator.migrate(mockTempDir) } returns mockMigratedDir + + mockkObject(TargetTranslation) + coEvery { TargetTranslation.open(any(), any()) } returns mockTarget + + coEvery { translator.getTargetTranslation(any()) } returns null + + val component = createComponent() + component.importRepo(repoItem, accepted = true, overwrite = false) + + component.state.awaitState(timeoutMs = 3000) { it.resultMessage != null } + coVerify { translator.restoreTargetTranslation(mockTarget) } + assertTrue(resultReceived is ImportComponent.Result.ProjectsImported) + assertEquals(mockTarget.id, (resultReceived as ImportComponent.Result.ProjectsImported).translationIds.first()) + } + } + + @Test + fun testCloneRepositoryAuthFailureNoKeys() { + runBlocking { + val repoItem = RepositoryItem( + languageName = "English", + projectName = "Genesis", + targetTranslationSlug = "en_gen_text", + languageCode = "en", + languageDirection = "ltr", + repoName = "user/en_gen_text", + url = "http://repo.url", + isPrivate = false, + unsupportedTag = "" + ) + + coEvery { cloneRepository.execute(repoItem.url, any()) } returns + CloneRepository.Result(CloneRepository.Status.AUTH_FAILURE, repoItem.url, null) + + every { directoryProvider.hasSSHKeys() } returns false + coEvery { registerSSHKeys.execute(false, any()) } returns true + + val component = createComponent() + component.importRepo(repoItem, accepted = true, overwrite = false) + + delay(300) + coVerify { registerSSHKeys.execute(false, any()) } + } + } + + @Test + fun testCloneRepositoryAuthFailureWithKeys() { + runBlocking { + val repoItem = RepositoryItem( + languageName = "English", + projectName = "Genesis", + targetTranslationSlug = "en_gen_text", + languageCode = "en", + languageDirection = "ltr", + repoName = "user/en_gen_text", + url = "http://repo.url", + isPrivate = false, + unsupportedTag = "" + ) + + coEvery { cloneRepository.execute(repoItem.url, any()) } returns + CloneRepository.Result(CloneRepository.Status.AUTH_FAILURE, repoItem.url, null) + + every { directoryProvider.hasSSHKeys() } returns true + + val component = createComponent() + component.importRepo(repoItem, accepted = true, overwrite = false) + + val event = component.event.awaitEvent(timeoutMs = 3000) + assertTrue(event is ImportComponent.Event.AuthRequested) + assertEquals(repoItem, component.state.value.repoToImport) + } + } + + @Test + fun testCloneRepositoryMergeConflictNoMerge() { + runBlocking { + val repoItem = RepositoryItem( + languageName = "English", + projectName = "Genesis", + targetTranslationSlug = "en_gen_text", + languageCode = "en", + languageDirection = "ltr", + repoName = "user/en_gen_text", + url = "http://repo.url", + isPrivate = false, + unsupportedTag = "" + ) + + val mockTempDir = File.createTempFile("temp_dir", "") + mockTempDir.delete() + mockTempDir.mkdirs() + mockTempDir.deleteOnExit() + + coEvery { cloneRepository.execute(repoItem.url, any()) } returns + CloneRepository.Result(CloneRepository.Status.SUCCESS, repoItem.url, mockTempDir) + + val mockMigratedDir = File.createTempFile("migrated_dir", "") + mockMigratedDir.delete() + mockMigratedDir.mkdirs() + mockMigratedDir.deleteOnExit() + + coEvery { targetTranslationMigrator.migrate(mockTempDir, any()) } returns mockMigratedDir + coEvery { targetTranslationMigrator.migrate(mockTempDir) } returns mockMigratedDir + + mockkObject(TargetTranslation) + coEvery { TargetTranslation.open(any(), any()) } returns mockTarget + + val existingTarget = mockk(relaxed = true) { + every { id } returns "target-1" + coEvery { merge(any(), any()) } returns false + } + coEvery { translator.getTargetTranslation(any()) } returns existingTarget + coEvery { translator.getTargetTranslation("target-1") } returns existingTarget + + val component = createComponent() + component.importRepo(repoItem, accepted = true, overwrite = false) + + component.state.awaitState(timeoutMs = 3000) { it.mergeConflict != null } + val conflict = component.state.value.mergeConflict + assertNotNull(conflict) + assertEquals(existingTarget, conflict.translation) + assertTrue(conflict.hasMergeConflict) + assertTrue(conflict.isFromServer) + } + } + + @Test + fun testMergeConflictCallbacksOverwrite() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "project.tstudio" + } + coEvery { importProjects.importProject(mockFile, any(), any()) } returns + ImportProjects.ImportPlatformFileResult( + file = mockFile, + importedSlug = "target-1", + success = true, + hasMergeConflict = true, + alreadyExists = true, + invalidFileName = false + ) + + val component = createComponent() + component.importProject(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.mergeConflict != null } + val conflict = component.state.value.mergeConflict + assertNotNull(conflict) + + coEvery { importProjects.importProject(mockFile, true, any()) } returns + ImportProjects.ImportPlatformFileResult( + file = mockFile, + importedSlug = "target-1", + success = true, + hasMergeConflict = false, + alreadyExists = false, + invalidFileName = false + ) + + conflict.onOverwrite() + + delay(300) + coVerify { importProjects.importProject(mockFile, true, any()) } + } + } + + @Test + fun testMergeConflictCallbacksCancel() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "project.tstudio" + } + coEvery { importProjects.importProject(mockFile, any(), any()) } returns + ImportProjects.ImportPlatformFileResult( + file = mockFile, + importedSlug = "target-1", + success = true, + hasMergeConflict = true, + alreadyExists = true, + invalidFileName = false + ) + + val component = createComponent() + component.importProject(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.mergeConflict != null } + val conflict = component.state.value.mergeConflict + assertNotNull(conflict) + + conflict.onCancel() + + delay(300) + coVerify { mockTarget.resetToMasterBackup() } + } + } + + @Test + fun testMergeConflictCallbacksResolve() { + runBlocking { + val mockFile = mockk(relaxed = true) { + every { displayName } returns "project.tstudio" + } + coEvery { importProjects.importProject(mockFile, any(), any()) } returns + ImportProjects.ImportPlatformFileResult( + file = mockFile, + importedSlug = "target-1", + success = true, + hasMergeConflict = true, + alreadyExists = true, + invalidFileName = false + ) + + val component = createComponent() + component.importProject(mockFile, false) + + component.state.awaitState(timeoutMs = 3000) { it.mergeConflict != null } + val conflict = component.state.value.mergeConflict + assertNotNull(conflict) + + conflict.onResolve() + + assertTrue(resultReceived is ImportComponent.Result.MergeConflict) + assertEquals("target-1", (resultReceived as ImportComponent.Result.MergeConflict).translationId) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/import/ImportUsfmComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/import/ImportUsfmComponentTest.kt new file mode 100644 index 0000000..4972676 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/import/ImportUsfmComponentTest.kt @@ -0,0 +1,96 @@ +package org.bibletranslationtools.writer.unit.ui.dialogs.import + +import io.github.vinceglb.filekit.PlatformFile +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecatalog.library.models.TargetLanguage +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.ProcessUSFM +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.ui.dialogs.import.DefaultImportUsfmComponent +import org.bibletranslationtools.writer.ui.dialogs.import.ImportUsfmComponent +import org.bibletranslationtools.writer.ui.dialogs.import.UsfmStep +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.ImportProjects +import org.jetbrains.compose.resources.getString +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals + +class ImportUsfmComponentTest : BaseComponentTest() { + + private val translator: Translator = mockk(relaxed = true) + private val importProjects: ImportProjects = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val processUSFM: ProcessUSFM = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + private lateinit var testFile: java.io.File + private lateinit var platformFile: PlatformFile + private var resultReceived: ImportUsfmComponent.Result? = null + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(any()) } returns "Mock String" + + testFile = java.io.File.createTempFile("genesis", ".usfm") + testFile.deleteOnExit() + platformFile = PlatformFile(testFile) + + startKoin { + modules( + module { + single { translator } + single { importProjects } + single { catalogClient } + single { processUSFM } + single { platform } + } + ) + } + resultReceived = null + } + + @After + fun tearDownComponent() { + if (::testFile.isInitialized && testFile.exists()) { + testFile.delete() + } + } + + private fun createComponent(): DefaultImportUsfmComponent = + createComponent { context -> + DefaultImportUsfmComponent( + componentContext = context, + file = platformFile, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitializationLoadsLanguages() { + runBlocking { + val mockTargetLanguage = mockk(relaxed = true) { + every { name } returns "English" + every { slug } returns "en" + } + coEvery { catalogClient.library.getTargetLanguages() } returns listOf(mockTargetLanguage) + + val component = createComponent() + component.state.awaitState { it.languages.isNotEmpty() } + + assertEquals(UsfmStep.LANGUAGE, component.state.value.step) + assertEquals(1, component.state.value.languages.size) + assertEquals("en", component.state.value.languages.first().slug) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/source/SelectSourcesComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/source/SelectSourcesComponentTest.kt new file mode 100644 index 0000000..0928b1e --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/source/SelectSourcesComponentTest.kt @@ -0,0 +1,186 @@ +package org.bibletranslationtools.writer.unit.ui.dialogs.source + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecatalog.library.models.Translation +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.ui.dialogs.source.DefaultSelectSourcesComponent +import org.bibletranslationtools.writer.ui.dialogs.source.SelectSourcesComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.DownloadResourceContainers +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class SelectSourcesComponentTest : BaseComponentTest() { + + private val translator: Translator = mockk(relaxed = true) + private val preference: Preference = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val downloadResourceContainers: DownloadResourceContainers = mockk(relaxed = true) + + private var resultReceived: SelectSourcesComponent.Result? = null + + private val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { projectId } returns "gen" + } + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(any()) } returns "Mock String" + coEvery { getString(any(), *anyVararg()) } returns "Mock String" + + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + + startKoin { + modules( + module { + single { translator } + single { preference } + single { catalogClient } + single { downloadResourceContainers } + } + ) + } + resultReceived = null + } + + private fun createComponent(translationId: String = "target-1"): DefaultSelectSourcesComponent = + createComponent { context -> + DefaultSelectSourcesComponent( + componentContext = context, + translationId = translationId, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitializationLoadsSources() { + runBlocking { + every { preference.getOpenSourceTranslations("target-1") } returns listOf("en-ulb") + val mockTranslation = mockk(relaxed = true) { + every { language.name } returns "English" + every { language.slug } returns "en" + every { resource.name } returns "Unlocked Literal Bible" + every { resourceContainerSlug } returns "en-ulb" + } + coEvery { catalogClient.library.getTranslation("en-ulb") } returns mockTranslation + coEvery { catalogClient.library.findTranslations(any(), any(), any(), any(), any(), any(), any()) } returns emptyList() + coEvery { catalogClient.resourceContainerExists("en-ulb") } returns true + + val component = createComponent() + component.state.awaitState { it.sources.isNotEmpty() } + + assertEquals(1, component.state.value.sources.size) + val source = component.state.value.sources.first() + assertEquals("en-ulb", source.containerSlug) + assertTrue(source.selected) + assertTrue(source.downloaded) + } + } + + @Test + fun testInitializationTranslationNotFound() { + runBlocking { + coEvery { translator.getTargetTranslation("unknown") } returns null + + createComponent(translationId = "unknown") + assertNotNull(resultReceived) + assertTrue(resultReceived is SelectSourcesComponent.Result.Error) + } + } + + @Test + fun testConfirmSources() { + runBlocking { + every { preference.getOpenSourceTranslations("target-1") } returns listOf("en-ulb") + val mockTranslation = mockk(relaxed = true) { + every { language.name } returns "English" + every { language.slug } returns "en" + every { resource.name } returns "Unlocked Literal Bible" + every { resourceContainerSlug } returns "en-ulb" + } + coEvery { catalogClient.library.getTranslation("en-ulb") } returns mockTranslation + + val component = createComponent() + component.state.awaitState { it.sources.isNotEmpty() } + + component.onConfirmSources() + assertNotNull(resultReceived) + assertTrue(resultReceived is SelectSourcesComponent.Result.ConfirmedSources) + val confirmed = (resultReceived as SelectSourcesComponent.Result.ConfirmedSources).sources + assertTrue(confirmed.contains("en-ulb")) + } + } + + @Test + fun testUpdateSources() { + runBlocking { + val component = createComponent() + component.onUpdateSources() + assertNotNull(resultReceived) + assertTrue(resultReceived is SelectSourcesComponent.Result.UpdateSources) + } + } + + @Test + fun testToggleSelection() { + runBlocking { + every { preference.getOpenSourceTranslations("target-1") } returns listOf("en-ulb") + val mockTranslation = mockk(relaxed = true) { + every { language.name } returns "English" + every { language.slug } returns "en" + every { resource.name } returns "Unlocked Literal Bible" + every { resourceContainerSlug } returns "en-ulb" + } + coEvery { catalogClient.library.getTranslation("en-ulb") } returns mockTranslation + + val component = createComponent() + component.state.awaitState { it.sources.isNotEmpty() } + + val item = component.state.value.sources.first() + assertTrue(item.selected) + + component.toggleSelection(item) + component.state.awaitState { !it.sources.first().selected } + assertTrue(!component.state.value.sources.first().selected) + } + } + + @Test + fun testDeleteSource() { + runBlocking { + every { preference.getOpenSourceTranslations("target-1") } returns listOf("en-ulb") + val mockTranslation = mockk(relaxed = true) { + every { language.name } returns "English" + every { language.slug } returns "en" + every { resource.name } returns "Unlocked Literal Bible" + every { resourceContainerSlug } returns "en-ulb" + } + coEvery { catalogClient.library.getTranslation("en-ulb") } returns mockTranslation + + val component = createComponent() + component.state.awaitState { it.sources.isNotEmpty() } + + val item = component.state.value.sources.first() + component.deleteSource(item) + + coVerify { catalogClient.deleteResourceContainer("en-ulb") } + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/update/UpdateLibraryComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/update/UpdateLibraryComponentTest.kt new file mode 100644 index 0000000..276ccba --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/dialogs/update/UpdateLibraryComponentTest.kt @@ -0,0 +1,190 @@ +package org.bibletranslationtools.writer.unit.ui.dialogs.update + +import io.github.vinceglb.filekit.PlatformFile +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.writer.ui.dialogs.update.DefaultUpdateLibraryComponent +import org.bibletranslationtools.writer.ui.dialogs.update.UpdateLibraryComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitEvent +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.CheckForLatestRelease +import org.bibletranslationtools.writer.usecases.ImportIndex +import org.bibletranslationtools.writer.usecases.UpdateCatalogs +import org.bibletranslationtools.writer.usecases.UpdateSource +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class UpdateLibraryComponentTest : BaseComponentTest() { + + private val importIndex: ImportIndex = mockk(relaxed = true) + private val updateCatalogs: UpdateCatalogs = mockk(relaxed = true) + private val checkForLatestRelease: CheckForLatestRelease = mockk(relaxed = true) + private val updateSource: UpdateSource = mockk(relaxed = true) + + private var resultReceived: UpdateLibraryComponent.Result? = null + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(any()) } returns "Mock String" + coEvery { getString(any(), *anyVararg()) } returns "Mock String" + + startKoin { + modules( + module { + single { importIndex } + single { updateCatalogs } + single { checkForLatestRelease } + single { updateSource } + } + ) + } + resultReceived = null + } + + private fun createComponent(triggerUpdate: Boolean = false): DefaultUpdateLibraryComponent = + createComponent { context -> + DefaultUpdateLibraryComponent( + componentContext = context, + triggerUpdate = triggerUpdate, + onResult = { resultReceived = it } + ) + } + + @Test + fun testOpenDownloadSources() { + val component = createComponent() + component.openDownloadSources() + assertNotNull(resultReceived) + assertTrue(resultReceived is UpdateLibraryComponent.Result.OpenDownloadSources) + } + + @Test + fun testUpdateSourcesSuccess() { + runBlocking { + val mockResult = UpdateSource.Result(success = true, updatedCount = 2, addedCount = 1) + coEvery { updateSource.execute(any()) } returns mockResult + + val component = createComponent() + component.updateSources() + + component.state.awaitState { it.updateSourceResult != null } + assertEquals(mockResult, component.state.value.updateSourceResult) + } + } + + @Test + fun testImportIndexSqliteSuccess() { + runBlocking { + coEvery { importIndex.import(any()) } returns true + + val tempFile = File.createTempFile("test", ".sqlite") + tempFile.deleteOnExit() + val platformFile = PlatformFile(tempFile) + + val component = createComponent() + component.importIndex(platformFile) + + val event = component.event.awaitEvent { it is UpdateLibraryComponent.Event.IndexUpdated } + assertNotNull(event) + + tempFile.delete() + } + } + + @Test + fun testImportIndexNotSqlite() { + runBlocking { + val tempFile = File.createTempFile("test", ".txt") + tempFile.deleteOnExit() + val platformFile = PlatformFile(tempFile) + + val component = createComponent() + component.importIndex(platformFile) + + component.state.awaitState { it.resultMessage != null } + assertNotNull(component.state.value.resultMessage) + coVerify(exactly = 0) { importIndex.import(any()) } + + tempFile.delete() + } + } + + @Test + fun testDownloadIndexSuccess() { + runBlocking { + coEvery { importIndex.download(any()) } returns true + + val component = createComponent() + component.downloadIndex() + + val event = component.event.awaitEvent { it is UpdateLibraryComponent.Event.IndexUpdated } + assertNotNull(event) + } + } + + @Test + fun testUpdateLanguagesSuccess() { + runBlocking { + val mockResult = UpdateCatalogs.Result(success = true, addedCount = 3) + coEvery { updateCatalogs.execute(any(), any()) } returns mockResult + + val component = createComponent() + component.updateLanguages() + + component.state.awaitState { it.resultMessage != null } + assertNotNull(component.state.value.resultMessage) + } + } + + @Test + fun testCheckAppUpdateWithRelease() { + runBlocking { + val mockRelease = CheckForLatestRelease.Release( + name = "v1.0.0", + downloadUrl = "http://download.url", + downloadSize = 1024, + build = 2 + ) + coEvery { checkForLatestRelease.execute() } returns CheckForLatestRelease.Result(mockRelease) + + val component = createComponent() + component.checkAppUpdate() + + component.state.awaitState { it.latestRelease != null } + assertEquals(mockRelease, component.state.value.latestRelease) + } + } + + @Test + fun testClearMethods() { + runBlocking { + val mockRelease = CheckForLatestRelease.Release( + name = "v1.0.0", + downloadUrl = "http://download.url", + downloadSize = 1024, + build = 2 + ) + coEvery { checkForLatestRelease.execute() } returns CheckForLatestRelease.Result(mockRelease) + + val component = createComponent() + component.checkAppUpdate() + component.state.awaitState { it.latestRelease != null } + + component.clearLatestRelease() + component.state.awaitState { it.latestRelease == null } + assertEquals(component.state.value.latestRelease, null) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/draft/DraftComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/draft/DraftComponentTest.kt new file mode 100644 index 0000000..378e570 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/draft/DraftComponentTest.kt @@ -0,0 +1,147 @@ +package org.bibletranslationtools.writer.unit.ui.draft + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecontainer.ResourceContainer +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.rendering.RenderingProvider +import org.bibletranslationtools.writer.ui.draft.DefaultDraftComponent +import org.bibletranslationtools.writer.ui.draft.DraftComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.ImportDraft +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +class DraftComponentTest : BaseComponentTest() { + + private val translator: Translator = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val importDraft: ImportDraft = mockk(relaxed = true) + + private var resultReceived: DraftComponent.Result? = null + + private val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { targetLanguage.slug } returns "en" + every { projectId } returns "gen" + } + + private val mockResource = mockk(relaxed = true) { + every { slug } returns "reg" + } + + private val mockCatalogTranslation = mockk(relaxed = true) { + every { resource } returns mockResource + } + + @Before + fun setUpComponent() { + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + every { + catalogClient.library.findTranslations( + any(), any(), any(), any(), any(), any(), any() + ) + } returns listOf(mockCatalogTranslation) + + startKoin { + modules( + module { + single { translator } + single { catalogClient } + single { importDraft } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultDraftComponent = + createComponent { componentContext -> + DefaultDraftComponent( + componentContext = componentContext, + translationId = "target-1", + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitializationLoadsDraftTranslations() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.draftTranslations.isNotEmpty() } + + assertEquals(1, component.state.value.draftTranslations.size) + assertEquals("reg", component.state.value.draftTranslations.first().resource.slug) + } + } + + @Test + fun testNavigateBack() { + val component = createComponent() + component.onNavigateBack() + assertEquals(DraftComponent.Result.NavigateBack, resultReceived) + } + + @Test + fun testOnFinish() { + val component = createComponent() + component.onFinish() + assertEquals(DraftComponent.Result.NavigateBack, resultReceived) + } + + @Test + fun testGetResourceContainer() { + val component = createComponent() + val mockContainer = mockk(relaxed = true) + every { catalogClient.openResourceContainer("en_gen") } returns mockContainer + + val result = component.getResourceContainer("en_gen") + assertNotNull(result) + assertEquals(mockContainer, result) + } + + @Test + fun testImportDraft() { + runBlocking { + val component = createComponent() + val mockContainer = mockk(relaxed = true) + val mockImportResult = mockk(relaxed = true) + + coEvery { importDraft.execute(mockContainer, any()) } returns mockImportResult + + component.importDraft(mockContainer) + + component.state.awaitState { it.importResult != null } + assertEquals(mockImportResult, component.state.value.importResult) + } + } + + @Test + fun testParseChapterContent() { + runBlocking { + val component = createComponent() + val mockContainer = mockk(relaxed = true) { + every { readChunk("1", "title") } returns "Genesis 1" + every { chunks("1") } returns listOf("01", "02") + every { readChunk("1", "01") } returns "In the beginning " + every { readChunk("1", "02") } returns "God created the heavens." + every { info.contentMimeType } returns "text/usfm" + } + val mockRenderingProvider = mockk(relaxed = true) + + val chapterContent = component.parseChapterContent("1", mockContainer, mockRenderingProvider) + + assertEquals("Genesis 1", chapterContent.title) + assertEquals(1, chapterContent.renderNodes.size) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/home/HomeComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/home/HomeComponentTest.kt new file mode 100644 index 0000000..715bfac --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/home/HomeComponentTest.kt @@ -0,0 +1,551 @@ +package org.bibletranslationtools.writer.unit.ui.home + +import io.github.vinceglb.filekit.PlatformFile +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecatalog.library.models.Translation +import org.bibletranslationtools.resourcecontainer.Project +import org.bibletranslationtools.writer.DirectoryProvider +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.data.getPref +import org.bibletranslationtools.writer.data.setPref +import org.bibletranslationtools.writer.ui.dialogs.export.ExportComponent +import org.bibletranslationtools.writer.ui.dialogs.import.ImportComponent +import org.bibletranslationtools.writer.ui.dialogs.import.ImportUsfmComponent +import org.bibletranslationtools.writer.ui.dialogs.update.UpdateLibraryComponent +import org.bibletranslationtools.writer.ui.home.BookSort +import org.bibletranslationtools.writer.ui.home.DefaultHomeComponent +import org.bibletranslationtools.writer.ui.home.HomeComponent +import org.bibletranslationtools.writer.ui.home.ProjectSort +import org.bibletranslationtools.writer.ui.home.TranslationItem +import org.bibletranslationtools.writer.ui.navigation.RootComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitEvent +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.BackupRC +import org.bibletranslationtools.writer.usecases.DownloadResourceContainers +import org.bibletranslationtools.writer.usecases.GetAvailableSources +import org.bibletranslationtools.writer.usecases.GogsLogout +import org.bibletranslationtools.writer.usecases.TranslationProgress +import org.bibletranslationtools.writer.usecases.UpdateSource +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HomeComponentTest : BaseComponentTest() { + + private val translator: Translator = mockk(relaxed = true) + private val preference: Preference = mockk(relaxed = true) + private val calculateProgress: TranslationProgress = mockk(relaxed = true) + private val profile: Profile = mockk(relaxed = true) + private val gogsLogout: GogsLogout = mockk(relaxed = true) + private val backupRC: BackupRC = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + private val getAvailableSources: GetAvailableSources = mockk(relaxed = true) + private val downloadResourceContainers: DownloadResourceContainers = mockk(relaxed = true) + private val directoryProvider: DirectoryProvider = mockk(relaxed = true) + private val updateSource: UpdateSource = mockk(relaxed = true) + + private val sharedFlow = MutableSharedFlow() + private var resultReceived: HomeComponent.Result? = null + + private val mockProject = mockk(relaxed = true) { + every { name } returns "Genesis" + } + private val mockTranslation = mockk(relaxed = true) { + every { project } returns mockProject + } + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(any()) } returns "Mock String" + coEvery { getString(any(), *anyVararg()) } returns "Mock String" + + every { preference.getPref(any(), any(), any()) } answers { args[1]!! } + every { catalogClient.library.getTranslation(any()) } returns mockTranslation + every { catalogClient.library.getProject(any(), any(), any()) } returns mockProject + + startKoin { + modules( + module { + single { translator } + single { preference } + single { calculateProgress } + single { profile } + single { gogsLogout } + single { backupRC } + single { catalogClient } + single { platform } + single { getAvailableSources } + single { downloadResourceContainers } + single { directoryProvider } + single { updateSource } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultHomeComponent = createComponent { context -> + DefaultHomeComponent( + componentContext = context, + sharedFlow = sharedFlow, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitializationLoadsPreferencesAndProjects() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + every { preference.getPref("sort_by_project", 0) } returns ProjectSort.LanguageThenProject.ordinal + every { preference.getPref("sort_by_book", 0) } returns BookSort.Alphabetical.ordinal + + val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { projectId } returns "gen" + every { targetLanguageName } returns "English" + every { resourceSlug } returns "reg" + } + coEvery { translator.getTargetTranslations() } returns listOf(mockTarget) + coEvery { calculateProgress.execute(mockTarget) } returns 0.5f + + val component = createComponent() + + // Wait for projects list to load in state + component.state.awaitState { it.translations.isNotEmpty() } + + assertEquals(ProjectSort.LanguageThenProject, component.state.value.projectSort) + assertEquals(BookSort.Alphabetical, component.state.value.bookSort) + assertEquals(1, component.state.value.translations.size) + assertEquals("target-1", component.state.value.translations.first().translation.id) + assertEquals(0.5f, component.state.value.translations.first().progress) + } + } + + @Test + fun testLastOpenedProjectAutoOpens() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns "last-opened-id" + val mockTarget = mockk(relaxed = true) { + every { id } returns "last-opened-id" + } + coEvery { translator.getTargetTranslation("last-opened-id") } returns mockTarget + + createComponent() + + // Yield coroutines to trigger getLastOpened logic in init + delayYield() + + assertEquals(HomeComponent.Result.OpenProject("last-opened-id", false), resultReceived) + } + } + + @Test + fun testDeleteProject() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val mockTarget = mockk(relaxed = true) { + every { id } returns "target-to-delete" + every { projectId } returns "gen" + } + coEvery { translator.getTargetTranslations() } returns listOf(mockTarget) + + val component = createComponent() + component.state.awaitState { it.translations.isNotEmpty() } + + val itemToDelete = component.state.value.translations.first() + component.deleteProject(itemToDelete) + + // Wait for translations to be empty + component.state.awaitState { it.translations.isEmpty() } + + coVerify { backupRC.backupTargetTranslation(mockTarget, false) } + coVerify { translator.deleteTargetTranslation("target-to-delete") } + coVerify { preference.clearTargetTranslationSettings("target-to-delete") } + } + } + + @Test + fun testChangeSorting() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + + component.changeProjectSort(ProjectSort.ProgressThenProject) + assertEquals(ProjectSort.ProgressThenProject, component.state.value.projectSort) + verify { preference.setPref("sort_by_project", ProjectSort.ProgressThenProject.ordinal) } + + component.changeBookSort(BookSort.Alphabetical) + assertEquals(BookSort.Alphabetical, component.state.value.bookSort) + verify { preference.setPref("sort_by_book", BookSort.Alphabetical.ordinal) } + } + } + + @Test + fun testDialogNavigation() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + + assertNull(component.dialogSlot.value.child) + + // Feedback + component.showFeedbackDialog() + assertTrue(component.dialogSlot.value.child?.instance is HomeComponent.DialogChild.Feedback) + + // Dismiss + component.dismissDialog() + assertNull(component.dialogSlot.value.child) + + // Export + component.showExportDialog("target-1", true) + assertTrue(component.dialogSlot.value.child?.instance is HomeComponent.DialogChild.Export) + + // Dismiss again + component.dismissDialog() + assertNull(component.dialogSlot.value.child) + } + } + + @Test + fun testLogout() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + + component.logout() + + // Wait for Logout result + delayYield() + + coVerify { gogsLogout.execute() } + coVerify { profile.logout() } + assertEquals(HomeComponent.Result.Logout, resultReceived) + } + } + + @Test + fun testSharedFlowEvents() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + + val job = launch { + sharedFlow.emit(RootComponent.SharedEvent.SnackbarMessage("Hello World")) + } + + val event = component.event.awaitEvent { it is HomeComponent.Event.SnackbarMessage } + assertEquals("Hello World", (event as HomeComponent.Event.SnackbarMessage).message) + + job.cancel() + } + } + + @Test + fun testProjectInfoState() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + val mockItem = mockk(relaxed = true) + + assertNull(component.state.value.projectInfo) + component.showProjectInfo(mockItem) + assertEquals(mockItem, component.state.value.projectInfo) + + component.hideProjectInfo() + assertNull(component.state.value.projectInfo) + } + } + + @Test + fun testLoadWithProgress() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { projectId } returns "gen" + every { targetLanguageName } returns "English" + } + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + coEvery { calculateProgress.execute(mockTarget) } returns 0.75f + + val component = createComponent() + component.loadWithProgress(listOf("target-1")) + + component.state.awaitState { it.translations.any { item -> item.translation.id == "target-1" } } + val loaded = component.state.value.translations.first { it.translation.id == "target-1" } + assertEquals(0.75f, loaded.progress) + } + } + + @Test + fun testDirectCallbacks() { + runBlocking { + var lastFocusTranslation: String? = null + every { translator.lastFocusTargetTranslation } answers { lastFocusTranslation } + every { translator.lastFocusTargetTranslation = any() } answers { lastFocusTranslation = firstArg() } + + val component = createComponent() + + component.onNewTranslation() + assertEquals(HomeComponent.Result.OpenNewTranslation, resultReceived) + + component.onChangeTranslationLanguage(listOf("en"), "target-1") + assertEquals(HomeComponent.Result.ChangeTranslationLanguage(listOf("en"), "target-1"), resultReceived) + + component.openSettings() + assertEquals(HomeComponent.Result.OpenSettings, resultReceived) + + component.publishProject("target-1") + assertEquals(HomeComponent.Result.PublishProject("target-1"), resultReceived) + + component.openProject("target-1", true) + assertEquals(HomeComponent.Result.OpenProject("target-1", true), resultReceived) + assertEquals("target-1", component.lastFocusTargetTranslation) + + component.exitApp() + assertEquals(HomeComponent.Result.ExitApp, resultReceived) + + component.openLogin() + assertEquals(HomeComponent.Result.OpenLogin, resultReceived) + } + } + + @Test + fun testDialogsActivation() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + val mockFile = mockk(relaxed = true) + + assertNull(component.dialogSlot.value.child) + + // Import + component.importProject(mockFile) + assertTrue(component.dialogSlot.value.child?.instance is HomeComponent.DialogChild.Import) + + component.dismissDialog() + assertNull(component.dialogSlot.value.child) + + // Update library + component.requestUpdateLibrary() + assertTrue(component.dialogSlot.value.child?.instance is HomeComponent.DialogChild.UpdateLibrary) + } + } + + @Test + fun testShareAndExportToApp() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + + component.shareApp() + // Wait for coroutine inside shareApp to run + delayYield() + verify { platform.shareApp() } + + val mockFile = File("dummy") + component.exportToApp(mockFile) + verify { platform.shareProject(mockFile) } + } + } + + @Test + fun testBackCallbackTriggersExitDialog() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent { context -> + DefaultHomeComponent( + componentContext = context, + sharedFlow = sharedFlow, + onResult = { resultReceived = it } + ) + } + + val dispatcher = component.backHandler as? com.arkivanov.essenty.backhandler.BackDispatcher + dispatcher?.back() + + val event = component.event.awaitEvent { it is HomeComponent.Event.ShowExitDialog } + assertEquals(HomeComponent.Event.ShowExitDialog, event) + } + } + + @Test + fun testDuplicateProjectEvent() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { targetLanguageName } returns "English" + every { projectId } returns "gen" + } + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + val mockProj = mockk(relaxed = true) { + every { name } returns "Genesis" + } + every { catalogClient.library.getTranslation(any()) } returns null + every { catalogClient.library.getProject("English", "gen", true) } returns mockProj + + val component = createComponent() + + val job = launch { + sharedFlow.emit(RootComponent.SharedEvent.DuplicateProject("target-1")) + } + + val event = component.event.awaitEvent { it is HomeComponent.Event.SnackbarMessage } + assertTrue((event as HomeComponent.Event.SnackbarMessage).message.contains("Mock String")) + + job.cancel() + } + } + + @Test + fun testOnImportResultMergeConflict() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + + // MergeConflict should call openProject(result.translationId, true) + invokePrivateMethod(component, "onImportResult", ImportComponent.Result.MergeConflict("target-1")) + + assertEquals(HomeComponent.Result.OpenProject("target-1", true), resultReceived) + } + } + + @Test + fun testOnImportResultProjectsImported() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + + val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { projectId } returns "gen" + every { targetLanguageName } returns "English" + } + coEvery { translator.getTargetTranslations() } returns listOf(mockTarget) + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + + val component = createComponent() + + // ProjectsImported should reload translations + invokePrivateMethod(component, "onImportResult", ImportComponent.Result.ProjectsImported(listOf("target-1"))) + + component.state.awaitState { it.translations.any { item -> item.translation.id == "target-1" } } + assertTrue(component.state.value.translations.any { it.translation.id == "target-1" }) + } + } + + @Test + fun testOnImportResultOpenUsfmImport() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + val mockFile = mockk(relaxed = true) + + // OpenUsfmImport should activate ImportUsfm child dialog + invokePrivateMethod(component, "onImportResult", ImportComponent.Result.OpenUsfmImport(mockFile)) + + assertTrue(component.dialogSlot.value.child?.instance is HomeComponent.DialogChild.ImportUsfm) + } + } + + @Test + fun testOnUpdateLibraryResult() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + + // OpenDownloadSources should activate DownloadSources child dialog + invokePrivateMethod(component, "onUpdateLibraryResult", UpdateLibraryComponent.Result.OpenDownloadSources) + + assertTrue(component.dialogSlot.value.child?.instance is HomeComponent.DialogChild.DownloadSources) + } + } + + @Test + fun testOnImportUsfmResult() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { projectId } returns "gen" + every { targetLanguageName } returns "English" + } + coEvery { translator.getTargetTranslations() } returns listOf(mockTarget) + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + + // ProjectsImported should reload projects + invokePrivateMethod(component, "onImportUsfmResult", ImportUsfmComponent.Result.ProjectsImported(listOf("target-1"))) + component.state.awaitState { it.translations.any { item -> item.translation.id == "target-1" } } + assertTrue(component.state.value.translations.any { it.translation.id == "target-1" }) + + // MergeConflict should open project + invokePrivateMethod(component, "onImportUsfmResult", ImportUsfmComponent.Result.MergeConflict("target-1")) + assertEquals(HomeComponent.Result.OpenProject("target-1", true), resultReceived) + } + } + + @Test + fun testOnExportResult() { + runBlocking { + every { translator.lastFocusTargetTranslation } returns null + val component = createComponent() + + // ExportToApp + val mockFile = File("dummy") + invokePrivateMethod(component, "onExportResult", ExportComponent.Result.ExportToApp(mockFile)) + verify { platform.shareProject(mockFile) } + + // OpenLogin + invokePrivateMethod(component, "onExportResult", ExportComponent.Result.OpenLogin) + assertEquals(HomeComponent.Result.OpenLogin, resultReceived) + + // Logout + invokePrivateMethod(component, "onExportResult", ExportComponent.Result.Logout) + // Wait for logout async + delayYield() + assertEquals(HomeComponent.Result.Logout, resultReceived) + + // MergeConflict + invokePrivateMethod(component, "onExportResult", ExportComponent.Result.MergeConflict("target-1")) + assertEquals(HomeComponent.Result.OpenProject("target-1", true), resultReceived) + + // OpenFeedback + invokePrivateMethod(component, "onExportResult", ExportComponent.Result.OpenFeedback("message")) + assertTrue(component.dialogSlot.value.child?.instance is HomeComponent.DialogChild.Feedback) + } + } + + private fun invokePrivateMethod(instance: Any, name: String, vararg args: Any?) { + val method = instance::class.java.declaredMethods.first { it.name == name } + method.isAccessible = true + method.invoke(instance, *args) + } + + private suspend fun delayYield() { + delay(50) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/navigation/RootComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/navigation/RootComponentTest.kt new file mode 100644 index 0000000..09314f4 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/navigation/RootComponentTest.kt @@ -0,0 +1,141 @@ +package org.bibletranslationtools.writer.unit.ui.navigation + +import io.github.vinceglb.filekit.PlatformFile +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.logger.Logger +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.writer.DirectoryProvider +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.ui.navigation.DefaultRootComponent +import org.bibletranslationtools.writer.ui.navigation.RootComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.usecases.MigrateTranslations +import org.bibletranslationtools.writer.usecases.UpdateApp +import org.bibletranslationtools.writer.utils.RuntimeWrapper +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RootComponentTest : BaseComponentTest() { + + private val preference: Preference = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + private val directoryProvider: DirectoryProvider = mockk(relaxed = true) + private val migrateTranslations: MigrateTranslations = mockk() + private val updateApp: UpdateApp = mockk() + private val profile: Profile = mockk(relaxed = true) + private val translator: Translator = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + + private var appExited = false + + @Before + fun setUpComponent() { + mockkObject(RuntimeWrapper) + mockkObject(Logger) + + every { RuntimeWrapper.availableProcessors } returns 4 + every { RuntimeWrapper.maxMemory } returns 1024L * 1024 * 1024 + every { Logger.listStacktraces() } returns emptyList() + + every { preference.getPref(any(), any(), any()) } answers { args[1]!! } + + startKoin { + modules( + module { + single { preference } + single { platform } + single { directoryProvider } + single { migrateTranslations } + single { updateApp } + single { profile } + single { translator } + single { catalogClient } + } + ) + } + appExited = false + } + + private fun createComponent(): DefaultRootComponent = createComponent { context -> + DefaultRootComponent( + componentContext = context, + onExitApp = { appExited = true } + ) + } + + @Test + fun testInitialStackIsSplash() { + val component = createComponent() + val activeChild = component.stack.value.active.instance + + assertTrue(activeChild is RootComponent.Child.Splash) + } + + @Test + fun testNavigationToProfile() { + val component = createComponent() + component.openProfile(thenLogin = false) + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is RootComponent.Child.Profile) + } + + @Test + fun testNavigationToTranslate() { + val component = createComponent() + component.openTranslate(translationId = "test_project", startWithMergeFilter = false) + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is RootComponent.Child.Translate) + } + + @Test + fun testOnBackPressedPopsStack() { + val component = createComponent() + // Use openTranslate which uses bringToFront and pushes onto backstack + component.openTranslate(translationId = "test_project", startWithMergeFilter = false) + + assertTrue(component.stack.value.active.instance is RootComponent.Child.Translate) + + component.onBackPressed() + + assertTrue(component.stack.value.active.instance is RootComponent.Child.Splash) + } + + @Test + fun testOnDeepLinkEmitsImportEvent() = runBlocking { + val component = createComponent() + val mockFile = mockk() + + val events = mutableListOf() + val job = launch { + component.sharedFlow.collect { events.add(it) } + } + // Yield to allow the collector to register + delay(50) + + component.onDeepLink(mockFile) + + // Yield to allow emission processing + delay(50) + + assertEquals(1, events.size) + val event = events.first() + assertTrue(event is RootComponent.SharedEvent.ImportProject) + assertEquals(mockFile, event.file) + + job.cancel() + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/newtranslation/NewTranslationComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/newtranslation/NewTranslationComponentTest.kt new file mode 100644 index 0000000..510ce3a --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/newtranslation/NewTranslationComponentTest.kt @@ -0,0 +1,205 @@ +package org.bibletranslationtools.writer.unit.ui.newtranslation + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecatalog.library.models.CategoryEntry +import org.bibletranslationtools.resourcecatalog.library.models.TargetLanguage +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.ui.newtranslation.DefaultNewTranslationComponent +import org.bibletranslationtools.writer.ui.newtranslation.NewTranslationComponent +import org.bibletranslationtools.writer.ui.newtranslation.ScreenStep +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.MergeTargetTranslation +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals + +class NewTranslationComponentTest : BaseComponentTest() { + + private val mergeTargetTranslation: MergeTargetTranslation = mockk(relaxed = true) + private val preference: Preference = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val translator: Translator = mockk(relaxed = true) + private val profile: Profile = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + private var resultReceived: NewTranslationComponent.Result? = null + + private val mockLangEn = mockk(relaxed = true) { + every { slug } returns "en" + every { name } returns "English" + } + private val mockLangEs = mockk(relaxed = true) { + every { slug } returns "es" + every { name } returns "Spanish" + } + + private val mockCategory = mockk(relaxed = true) { + every { id } returns 101L + every { slug } returns "gen" + every { name } returns "Genesis" + } + + @Before + fun setUpComponent() { + every { platform.deviceLanguageCode } returns "en" + coEvery { catalogClient.library.getTargetLanguages() } returns listOf(mockLangEs, mockLangEn) + every { catalogClient.library.getProjectCategories(any(), any(), any()) } returns listOf(mockCategory) + every { preference.getPref(any(), any(), any()) } answers { args[1]!! } + + startKoin { + modules( + module { + single { mergeTargetTranslation } + single { preference } + single { catalogClient } + single { translator } + single { profile } + single { platform } + } + ) + } + resultReceived = null + } + + private fun createComponent( + disabledLanguages: List = emptyList(), + translationId: String? = null + ): DefaultNewTranslationComponent = createComponent { context -> + DefaultNewTranslationComponent( + componentContext = context, + disabledLanguages = disabledLanguages, + translationId = translationId, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitializationLoadsLanguages() { + runBlocking { + val component = createComponent(disabledLanguages = listOf("fr")) + component.state.awaitState { it.languages.isNotEmpty() } + + assertEquals(2, component.state.value.languages.size) + // Sorted by slug: en, then es + assertEquals("en", component.state.value.languages[0].slug) + assertEquals("es", component.state.value.languages[1].slug) + assertEquals(listOf("fr"), component.state.value.disabledLanguages) + } + } + + @Test + fun testOnLanguageSelectedNewTranslation() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.languages.isNotEmpty() } + + component.onLanguageSelected(mockLangEn) + + // Should display projects screen step + assertEquals(ScreenStep.PROJECT, component.state.value.screenStep) + assertEquals(1, component.state.value.categories.size) + assertEquals("gen", component.state.value.categories.first().slug) + assertEquals(mockLangEn, component.selectedTargetLanguage) + } + } + + @Test + fun testOnProjectSelectedSuccess() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.languages.isNotEmpty() } + + component.onLanguageSelected(mockLangEn) + + val mockTarget = mockk(relaxed = true) { + every { id } returns "en_gen_text_reg" + } + coEvery { translator.getTargetTranslation(any()) } returns null + coEvery { translator.createTargetTranslation(any(), any(), any(), any(), any(), any()) } returns mockTarget + + component.onProjectSelected("gen") + + // Wait for onResult + delayYield() + + assertEquals(NewTranslationComponent.Result.Success, resultReceived) + coVerify { translator.createTargetTranslation(any(), mockLangEn, "gen", any(), any(), any()) } + } + } + + @Test + fun testOnProjectSelectedDuplicate() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.languages.isNotEmpty() } + + component.onLanguageSelected(mockLangEn) + + val mockTarget = mockk(relaxed = true) { + every { id } returns "en_gen_text_reg" + } + coEvery { translator.getTargetTranslation("en_gen_text_reg") } returns mockTarget + + component.onProjectSelected("gen") + + delayYield() + + assertEquals(NewTranslationComponent.Result.Duplicate("en_gen_text_reg"), resultReceived) + } + } + + @Test + fun testCategoryNavigation() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.languages.isNotEmpty() } + + component.onLanguageSelected(mockLangEn) + + assertEquals(listOf(0L), component.state.value.categoryStack) + + component.onCategorySelected(101L) + assertEquals(listOf(0L, 101L), component.state.value.categoryStack) + + component.onCategoryBack() + assertEquals(listOf(0L), component.state.value.categoryStack) + } + } + + @Test + fun testSearchFiltering() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.languages.isNotEmpty() } + + component.onSearch("Spanish") + assertEquals("Spanish", component.state.value.searchQuery) + assertEquals(1, component.state.value.filteredLanguages.size) + assertEquals("es", component.state.value.filteredLanguages.first().slug) + } + } + + @Test + fun testNavigateBack() { + val component = createComponent() + component.navigateBack() + assertEquals(NewTranslationComponent.Result.NavigateBack, resultReceived) + } + + private suspend fun delayYield() { + delay(50) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/LoginOfflineComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/LoginOfflineComponentTest.kt new file mode 100644 index 0000000..7996bd7 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/LoginOfflineComponentTest.kt @@ -0,0 +1,55 @@ +package org.bibletranslationtools.writer.unit.ui.profile + +import io.mockk.mockk +import io.mockk.verify +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.ui.profile.DefaultLoginOfflineComponent +import org.bibletranslationtools.writer.ui.profile.LoginOfflineComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals + +class LoginOfflineComponentTest : BaseComponentTest() { + + private val profile: Profile = mockk(relaxed = true) + private var resultReceived: LoginOfflineComponent.Result? = null + + @Before + fun setUpComponent() { + startKoin { + modules( + module { + single { profile } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultLoginOfflineComponent = createComponent { context -> + DefaultLoginOfflineComponent( + componentContext = context, + onResult = { resultReceived = it } + ) + } + + @Test + fun testOnContinue() { + val component = createComponent() + component.onContinue("John Doe") + + verify { profile.login("John Doe") } + assertEquals(LoginOfflineComponent.Result.LoggedIn, resultReceived) + } + + @Test + fun testOnCancel() { + val component = createComponent() + component.onCancel() + + assertEquals(LoginOfflineComponent.Result.Back, resultReceived) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/LoginOnlineComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/LoginOnlineComponentTest.kt new file mode 100644 index 0000000..841473a --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/LoginOnlineComponentTest.kt @@ -0,0 +1,122 @@ +package org.bibletranslationtools.writer.unit.ui.profile + +import btt_writer.shared.generated.resources.Res +import btt_writer.shared.generated.resources.double_check_credentials +import btt_writer.shared.generated.resources.internet_not_available +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.gogsclient.User +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.ui.profile.DefaultLoginOnlineComponent +import org.bibletranslationtools.writer.ui.profile.LoginOnlineComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitEvent +import org.bibletranslationtools.writer.usecases.GogsLogin +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LoginOnlineComponentTest : BaseComponentTest() { + + private val profile: Profile = mockk(relaxed = true) + private val gogsLogin: GogsLogin = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + private var resultReceived: LoginOnlineComponent.Result? = null + + @Before + fun setUpComponent() { + startKoin { + modules( + module { + single { profile } + single { gogsLogin } + single { platform } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultLoginOnlineComponent = createComponent { context -> + DefaultLoginOnlineComponent( + componentContext = context, + onResult = { resultReceived = it } + ) + } + + @Test + fun testOnCancel() { + val component = createComponent() + component.onCancel() + + assertEquals(LoginOnlineComponent.Result.Back, resultReceived) + } + + @Test + fun testIsNetworkAvailable() { + every { platform.isNetworkAvailable } returns true + val component = createComponent() + assertTrue(component.isNetworkAvailable) + } + + @Test + fun testOnLoginSuccess() { + runBlocking { + val mockUser = mockk(relaxed = true) { + every { fullName } returns "Online User" + every { username } returns "online_user" + } + coEvery { gogsLogin.execute("online_user", "password", any()) } returns GogsLogin.LoginResult(mockUser) + + val component = createComponent() + component.onLogin("online_user", "password") + + // Allow flow and coroutine scope to process + var count = 0 + while (resultReceived == null && count < 20) { + kotlinx.coroutines.delay(50) + count++ + } + assertEquals(LoginOnlineComponent.Result.LoggedIn, resultReceived) + verify { profile.login("Online User", mockUser) } + } + } + + @Test + fun testOnLoginFailureNetworkAvailable() { + runBlocking { + coEvery { gogsLogin.execute(any(), any(), any()) } returns GogsLogin.LoginResult(null) + every { platform.isNetworkAvailable } returns true + + val component = createComponent() + component.onLogin("username", "password") + + val event = component.event.awaitEvent() + assertTrue(event is LoginOnlineComponent.Event.ShowError) + assertEquals(Res.string.double_check_credentials, event.errorResId) + } + } + + @Test + fun testOnLoginFailureNetworkUnavailable() { + runBlocking { + coEvery { gogsLogin.execute(any(), any(), any()) } returns GogsLogin.LoginResult(null) + every { platform.isNetworkAvailable } returns false + + val component = createComponent() + component.onLogin("username", "password") + + val event = component.event.awaitEvent() + assertTrue(event is LoginOnlineComponent.Event.ShowError) + assertEquals(Res.string.internet_not_available, event.errorResId) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/ProfileComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/ProfileComponentTest.kt new file mode 100644 index 0000000..f7cf3c6 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/ProfileComponentTest.kt @@ -0,0 +1,246 @@ +package org.bibletranslationtools.writer.unit.ui.profile + +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.gogsclient.User +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.ui.profile.DefaultProfileComponent +import org.bibletranslationtools.writer.ui.profile.ProfileComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.usecases.GogsLogin +import org.bibletranslationtools.writer.usecases.GogsLogout +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ProfileComponentTest : BaseComponentTest() { + + private val profile: Profile = mockk(relaxed = true) + private val gogsLogin: GogsLogin = mockk(relaxed = true) + private val logoutUseCase: GogsLogout = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + private var resultReceived: ProfileComponent.Result? = null + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(any()) } returns "10" + + startKoin { + modules( + module { + single { profile } + single { gogsLogin } + single { logoutUseCase } + single { platform } + } + ) + } + resultReceived = null + } + + private fun createComponent(goLogin: Boolean = false): DefaultProfileComponent = + createComponent { context -> + DefaultProfileComponent( + componentContext = context, + goLogin = goLogin, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitializationLoggedInAndTermsAccepted() { + every { profile.loggedIn } returns true + every { profile.termsOfUseLastAccepted } returns 10 + + createComponent() + + assertEquals(ProfileComponent.Result.LoggedIn, resultReceived) + } + + @Test + fun testInitializationNotLoggedIn() { + every { profile.loggedIn } returns false + + val component = createComponent(goLogin = false) + + // Stack should start with only ProfileIndex + val stack = component.stack.value + assertEquals(1, stack.items.size) + assertTrue(stack.active.instance is ProfileComponent.Child.Profile) + assertEquals(null, resultReceived) + } + + @Test + fun testInitializationWithGoLogin() { + every { profile.loggedIn } returns false + + val component = createComponent(goLogin = true) + + // Stack should contain Profile and LoginOnline + val stack = component.stack.value + assertEquals(2, stack.items.size) + assertTrue(stack.active.instance is ProfileComponent.Child.LoginOnline) + } + + @Test + fun testProfileIndexNavigationOnline() { + val component = createComponent(goLogin = false) + val profileChild = component.stack.value.active.instance as ProfileComponent.Child.Profile + + // Trigger LoginOnline result from ProfileIndex + profileChild.component.loginOnline() + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is ProfileComponent.Child.LoginOnline) + } + + @Test + fun testProfileIndexNavigationOffline() { + val component = createComponent(goLogin = false) + val profileChild = component.stack.value.active.instance as ProfileComponent.Child.Profile + + // Trigger LoginOffline result from ProfileIndex + profileChild.component.loginOffline() + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is ProfileComponent.Child.LoginOffline) + } + + @Test + fun testProfileIndexSettings() { + val component = createComponent(goLogin = false) + val profileChild = component.stack.value.active.instance as ProfileComponent.Child.Profile + + profileChild.component.settings() + + assertEquals(ProfileComponent.Result.OpenSettings, resultReceived) + } + + @Test + fun testProfileIndexCancel() { + val component = createComponent(goLogin = false) + val profileChild = component.stack.value.active.instance as ProfileComponent.Child.Profile + + profileChild.component.cancel() + + assertEquals(ProfileComponent.Result.Back, resultReceived) + } + + @Test + fun testLoginOnlineBack() { + val component = createComponent(goLogin = true) + val loginOnlineChild = component.stack.value.active.instance as ProfileComponent.Child.LoginOnline + + loginOnlineChild.component.onCancel() + + // Should pop and go back to Profile + assertEquals(1, component.stack.value.items.size) + assertTrue(component.stack.value.active.instance is ProfileComponent.Child.Profile) + } + + @Test + fun testLoginOfflineBack() { + val component = createComponent(goLogin = false) + val profileChild = component.stack.value.active.instance as ProfileComponent.Child.Profile + profileChild.component.loginOffline() + + val loginOfflineChild = component.stack.value.active.instance as ProfileComponent.Child.LoginOffline + loginOfflineChild.component.onCancel() + + // Should pop and go back to Profile + assertEquals(1, component.stack.value.items.size) + assertTrue(component.stack.value.active.instance is ProfileComponent.Child.Profile) + } + + @Test + fun testLoginOnlineLoggedInNavigatesToTerms() { + every { profile.loggedIn } returns false + val mockUser = mockk(relaxed = true) { + every { username } returns "testuser" + every { fullName } returns "Test User" + } + coEvery { gogsLogin.execute(any(), any(), any()) } returns GogsLogin.LoginResult(mockUser) + + val component = createComponent(goLogin = true) + val loginOnlineChild = component.stack.value.active.instance as ProfileComponent.Child.LoginOnline + + loginOnlineChild.component.onLogin("testuser", "password") + + runBlocking { + delay(300) + } + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is ProfileComponent.Child.TermsOfUse) + } + + @Test + fun testLoginOfflineLoggedInNavigatesToTerms() { + every { profile.loggedIn } returns false + + val component = createComponent(goLogin = false) + val profileChild = component.stack.value.active.instance as ProfileComponent.Child.Profile + profileChild.component.loginOffline() + + val loginOfflineChild = component.stack.value.active.instance as ProfileComponent.Child.LoginOffline + loginOfflineChild.component.onContinue("Test User") + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is ProfileComponent.Child.TermsOfUse) + } + + @Test + fun testTermsOfUseAcceptedTriggersLoggedIn() { + every { profile.loggedIn } returns false + + val component = createComponent(goLogin = false) + val profileChild = component.stack.value.active.instance as ProfileComponent.Child.Profile + profileChild.component.loginOffline() + + val loginOfflineChild = component.stack.value.active.instance as ProfileComponent.Child.LoginOffline + loginOfflineChild.component.onContinue("Test User") + + val termsOfUseChild = component.stack.value.active.instance as ProfileComponent.Child.TermsOfUse + termsOfUseChild.component.acceptTerms() + + runBlocking { + delay(300) + } + + assertEquals(ProfileComponent.Result.LoggedIn, resultReceived) + } + + @Test + fun testTermsOfUseRejectedClearsStackToProfile() { + every { profile.loggedIn } returns false + + val component = createComponent(goLogin = false) + val profileChild = component.stack.value.active.instance as ProfileComponent.Child.Profile + profileChild.component.loginOffline() + + val loginOfflineChild = component.stack.value.active.instance as ProfileComponent.Child.LoginOffline + loginOfflineChild.component.onContinue("Test User") + + val termsOfUseChild = component.stack.value.active.instance as ProfileComponent.Child.TermsOfUse + termsOfUseChild.component.rejectTerms() + + runBlocking { + delay(300) + } + + val stack = component.stack.value + assertEquals(1, stack.items.size) + assertTrue(stack.active.instance is ProfileComponent.Child.Profile) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/ProfileIndexComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/ProfileIndexComponentTest.kt new file mode 100644 index 0000000..0b9ac7c --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/ProfileIndexComponentTest.kt @@ -0,0 +1,47 @@ +package org.bibletranslationtools.writer.unit.ui.profile + +import org.bibletranslationtools.writer.ui.profile.DefaultProfileIndexComponent +import org.bibletranslationtools.writer.ui.profile.ProfileIndexComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.junit.Test +import kotlin.test.assertEquals + +class ProfileIndexComponentTest : BaseComponentTest() { + + private var resultReceived: ProfileIndexComponent.Result? = null + + private fun createComponent(): DefaultProfileIndexComponent = createComponent { context -> + DefaultProfileIndexComponent( + componentContext = context, + onResult = { resultReceived = it } + ) + } + + @Test + fun testLoginOnline() { + val component = createComponent() + component.loginOnline() + assertEquals(ProfileIndexComponent.Result.LoginOnline, resultReceived) + } + + @Test + fun testLoginOffline() { + val component = createComponent() + component.loginOffline() + assertEquals(ProfileIndexComponent.Result.LoginOffline, resultReceived) + } + + @Test + fun testSettings() { + val component = createComponent() + component.settings() + assertEquals(ProfileIndexComponent.Result.Settings, resultReceived) + } + + @Test + fun testCancel() { + val component = createComponent() + component.cancel() + assertEquals(ProfileIndexComponent.Result.Cancel, resultReceived) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/TermsOfUseComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/TermsOfUseComponentTest.kt new file mode 100644 index 0000000..0e6ecb8 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/profile/TermsOfUseComponentTest.kt @@ -0,0 +1,82 @@ +package org.bibletranslationtools.writer.unit.ui.profile + +import btt_writer.shared.generated.resources.Res +import btt_writer.shared.generated.resources.terms_of_use_version +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.ui.profile.DefaultTermsOfUseComponent +import org.bibletranslationtools.writer.ui.profile.TermsOfUseComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.usecases.GogsLogout +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals + +class TermsOfUseComponentTest : BaseComponentTest() { + + private val profile: Profile = mockk(relaxed = true) + private val logoutUseCase: GogsLogout = mockk(relaxed = true) + + private var resultReceived: TermsOfUseComponent.Result? = null + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(Res.string.terms_of_use_version) } returns "2" + + startKoin { + modules( + module { + single { profile } + single { logoutUseCase } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultTermsOfUseComponent = createComponent { context -> + DefaultTermsOfUseComponent( + componentContext = context, + onResult = { resultReceived = it } + ) + } + + @Test + fun testAcceptTerms() { + runBlocking { + val component = createComponent() + component.acceptTerms() + + // Wait for coroutine in component + delay(100) + + verify { profile.termsOfUseLastAccepted = 2 } + assertEquals(TermsOfUseComponent.Result.Accepted, resultReceived) + } + } + + @Test + fun testRejectTerms() { + runBlocking { + val component = createComponent() + component.rejectTerms() + + // Wait for coroutine in component + delay(100) + + coVerify { logoutUseCase.execute() } + verify { profile.logout() } + assertEquals(TermsOfUseComponent.Result.Rejected, resultReceived) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/publish/PublishComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/publish/PublishComponentTest.kt new file mode 100644 index 0000000..b496144 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/publish/PublishComponentTest.kt @@ -0,0 +1,186 @@ +package org.bibletranslationtools.writer.unit.ui.publish + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecatalog.library.models.SourceLanguage +import org.bibletranslationtools.resourcecatalog.library.models.TargetLanguage +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.TranslationFormat +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.core.Validation +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.ui.publish.DefaultPublishComponent +import org.bibletranslationtools.writer.ui.publish.PublishComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.ValidateProject +import org.jetbrains.compose.resources.getString +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PublishComponentTest : BaseComponentTest() { + + private val translator: Translator = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val validateProject: ValidateProject = mockk(relaxed = true) + private val profile: Profile = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + private val preference: Preference = mockk(relaxed = true) + + private var resultReceived: PublishComponent.Result? = null + + private val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { projectId } returns "gen" + } + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + coEvery { getString(any()) } returns "Mock String" + coEvery { getString(any(), *anyVararg()) } returns "Mock String" + + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + + startKoin { + modules( + module { + single { translator } + single { catalogClient } + single { validateProject } + single { profile } + single { platform } + single { preference } + } + ) + } + resultReceived = null + } + + private fun createComponent(translationId: String = "target-1"): DefaultPublishComponent = + createComponent { context -> + DefaultPublishComponent( + componentContext = context, + translationId = translationId, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitializationTranslationNotFound() { + runBlocking { + coEvery { translator.getTargetTranslation("unknown") } returns null + + createComponent(translationId = "unknown") + assertNotNull(resultReceived) + assertTrue(resultReceived is PublishComponent.Result.Error) + } + } + + @Test + fun testInitializationChooseSourceTranslationError() { + runBlocking { + every { preference.getSelectedSourceTranslationId("target-1") } returns null + every { catalogClient.library.getProject(any(), any(), any()) } returns null + + createComponent() + assertNotNull(resultReceived) + assertTrue(resultReceived is PublishComponent.Result.Error) + } + } + + @Test + fun testInitializationSuccess() { + runBlocking { + every { preference.getSelectedSourceTranslationId("target-1") } returns "en-ulb" + coEvery { validateProject.execute("target-1", "en-ulb") } returns emptyList() + + val component = createComponent() + component.state.awaitState { !it.isLoading } + + assertEquals(mockTarget, component.targetTranslation) + assertTrue(component.state.value.validations.isEmpty()) + } + } + + @Test + fun testNavigateBack() { + runBlocking { + every { preference.getSelectedSourceTranslationId("target-1") } returns "en-ulb" + + val component = createComponent() + component.navigateBack() + + assertNotNull(resultReceived) + assertTrue(resultReceived is PublishComponent.Result.NavigateBack) + } + } + + @Test + fun testOpenReview() { + runBlocking { + every { preference.getSelectedSourceTranslationId("target-1") } returns "en-ulb" + + val component = createComponent() + val mockSourceLang = mockk(relaxed = true) + val mockTargetLang = mockk(relaxed = true) + val item = Validation.InvalidFrame( + title = "Error Frame", + titleLanguage = mockSourceLang, + body = "Error detail", + bodyLanguage = mockTargetLang, + bodyFormat = TranslationFormat.USFM, + targetTranslationId = "target-1", + chapterId = "1", + frameId = "1" + ) + + component.openReview(item) + + coVerify { preference.setLastViewMode("target-1", org.bibletranslationtools.writer.core.TranslationViewMode.REVIEW) } + coVerify { preference.setLastFocus("target-1", "1", "1") } + assertNotNull(resultReceived) + assertTrue(resultReceived is PublishComponent.Result.OpenReview) + } + } + + @Test + fun testShowExportDialog() { + runBlocking { + every { preference.getSelectedSourceTranslationId("target-1") } returns "en-ulb" + + val component = createComponent() + component.showExportDialog() + + val slot = component.dialogSlot.value + assertNotNull(slot.child) + assertTrue(slot.child!!.instance is PublishComponent.DialogChild.Export) + } + } + + @Test + fun testDismissDialog() { + runBlocking { + every { preference.getSelectedSourceTranslationId("target-1") } returns "en-ulb" + + val component = createComponent() + component.showExportDialog() + assertNotNull(component.dialogSlot.value.child) + + component.dismissDialog() + assertEquals(component.dialogSlot.value.child, null) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/settings/SettingsComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/settings/SettingsComponentTest.kt new file mode 100644 index 0000000..c59f225 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/settings/SettingsComponentTest.kt @@ -0,0 +1,253 @@ +package org.bibletranslationtools.writer.unit.ui.settings + +import btt_writer.shared.generated.resources.Res +import btt_writer.shared.generated.resources.backup_intervals_values_array +import btt_writer.shared.generated.resources.content_server_account_create_urls_array +import btt_writer.shared.generated.resources.content_server_git_server_api_values_array +import btt_writer.shared.generated.resources.content_server_index_sqlite_url_array +import btt_writer.shared.generated.resources.content_server_lang_names_url_array +import btt_writer.shared.generated.resources.content_server_media_server_values_array +import btt_writer.shared.generated.resources.content_server_names_array +import btt_writer.shared.generated.resources.content_server_reader_server_values_array +import btt_writer.shared.generated.resources.content_server_values_array +import btt_writer.shared.generated.resources.font_size_values_array +import btt_writer.shared.generated.resources.pref_backup_interval_titles +import btt_writer.shared.generated.resources.pref_color_theme_titles +import btt_writer.shared.generated.resources.pref_logging_level_titles +import btt_writer.shared.generated.resources.pref_typeface_size_titles +import btt_writer.shared.generated.resources.pref_typeface_titles +import io.github.vinceglb.filekit.PlatformFile +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.writer.AppInfo +import org.bibletranslationtools.writer.DirectoryProvider +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.BackupScheduler +import org.bibletranslationtools.writer.core.Profile +import org.bibletranslationtools.writer.core.Typography +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.ui.settings.DefaultSettingsComponent +import org.bibletranslationtools.writer.ui.settings.SettingsComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.CheckForLatestRelease +import org.bibletranslationtools.writer.usecases.GogsLogout +import org.bibletranslationtools.writer.usecases.MigrateTranslations +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.getStringArray +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SettingsComponentTest : BaseComponentTest() { + + private val checkForLatestRelease: CheckForLatestRelease = mockk(relaxed = true) + private val profile: Profile = mockk(relaxed = true) + private val logout: GogsLogout = mockk(relaxed = true) + private val migrateTranslations: MigrateTranslations = mockk(relaxed = true) + private val preference: Preference = mockk(relaxed = true) + private val directoryProvider: DirectoryProvider = mockk(relaxed = true) + private val typography: Typography = mockk(relaxed = true) + private val backupScheduler: BackupScheduler = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + private var resultReceived: SettingsComponent.Result? = null + + @Before + fun setUpComponent() { + mockkStatic("org.jetbrains.compose.resources.StringResourcesKt") + mockkStatic("org.jetbrains.compose.resources.StringArrayResourcesKt") + coEvery { getString(any()) } returns "Mock String" + coEvery { getString(any(), *anyVararg()) } returns "Mock String" + + coEvery { getStringArray(Res.array.pref_color_theme_titles) } returns listOf("Light", "Dark") + coEvery { getStringArray(Res.array.pref_typeface_size_titles) } returns listOf("Small", "Large") + coEvery { getStringArray(Res.array.font_size_values_array) } returns listOf("14", "18") + coEvery { getStringArray(Res.array.content_server_names_array) } returns listOf("WACS", "Gitea") + coEvery { getStringArray(Res.array.content_server_values_array) } returns listOf("wacs_value", "gitea_value") + coEvery { getStringArray(Res.array.pref_backup_interval_titles) } returns listOf("Never", "5 Minutes") + coEvery { getStringArray(Res.array.backup_intervals_values_array) } returns listOf("-1", "300") + coEvery { getStringArray(Res.array.pref_logging_level_titles) } returns listOf("Debug", "Info", "Warning") + coEvery { getStringArray(Res.array.pref_typeface_titles) } returns listOf("System Default") + + val mockUrls = listOf("http://api1.url", "http://api2.url") + coEvery { getStringArray(Res.array.content_server_git_server_api_values_array) } returns mockUrls + coEvery { getStringArray(Res.array.content_server_media_server_values_array) } returns mockUrls + coEvery { getStringArray(Res.array.content_server_reader_server_values_array) } returns mockUrls + coEvery { getStringArray(Res.array.content_server_account_create_urls_array) } returns mockUrls + coEvery { getStringArray(Res.array.content_server_lang_names_url_array) } returns mockUrls + coEvery { getStringArray(Res.array.content_server_index_sqlite_url_array) } returns mockUrls + + every { platform.info } returns AppInfo( + versionName = "1.0.0", + versionCode = 1, + model = "Mock Model", + device = "Mock Device", + manufacturer = "Mock Manufacturer", + generator = "Mock Generator" + ) + every { directoryProvider.logFile } returns mockk(relaxed = true) + every { typography.getFontNames() } returns listOf("font1.ttf") + + every { preference.getPref(any(), any(), any()) } answers { args[1]!! } + + startKoin { + modules( + module { + single { checkForLatestRelease } + single { profile } + single { logout } + single { migrateTranslations } + single { preference } + single { directoryProvider } + single { typography } + single { backupScheduler } + single { platform } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultSettingsComponent = createComponent { context -> + DefaultSettingsComponent( + componentContext = context, + onResult = { resultReceived = it } + ) + } + + @Test + fun testInitializationLoadsPreferences() { + runBlocking { + every { preference.getPref(Preference.KEY_PREF_COLOR_THEME, any(), String::class) } returns "dark" + every { preference.getPref(Preference.KEY_PREF_TRANSLATION_TYPEFACE, any(), String::class) } returns "font1.ttf" + + val component = createComponent() + component.state.awaitState { it.currentThemeValue.isNotEmpty() } + + assertEquals("dark", component.state.value.currentThemeValue) + assertEquals("font1.ttf", component.state.value.currentTranslationFontValue) + } + } + + @Test + fun testUpdateColorTheme() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.themeValues.isNotEmpty() } + + component.updateColorTheme("dark") + + assertEquals("dark", component.state.value.currentThemeValue) + assertEquals(SettingsComponent.Result.ThemeUpdated("dark"), resultReceived) + } + } + + @Test + fun testUpdateTranslationTypeface() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.availableFonts.isNotEmpty() } + + component.updateTranslationTypeface("font1.ttf") + + assertEquals("font1.ttf", component.state.value.currentTranslationFontValue) + } + } + + @Test + fun testUpdateTranslationFontSize() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.fontSizeValues.isNotEmpty() } + + component.updateTranslationFontSize("18") + + assertEquals("18", component.state.value.currentTranslationFontSizeValue) + } + } + + @Test + fun testCheckForLatestRelease() { + runBlocking { + val mockResult = CheckForLatestRelease.Result(null) + coEvery { checkForLatestRelease.execute() } returns mockResult + + val component = createComponent() + component.checkForLatestRelease() + + component.state.awaitState { it.releaseResult != null } + assertEquals(mockResult, component.state.value.releaseResult) + } + } + + @Test + fun testMigrateOldAppData() { + runBlocking { + val tempFile = File.createTempFile("migration_dir", "") + tempFile.deleteOnExit() + val platformFile = PlatformFile(tempFile) + + val component = createComponent() + component.migrateOldAppData(platformFile) + + component.state.awaitState { it.migrationFinished } + assertTrue(component.state.value.migrationFinished) + + tempFile.delete() + } + } + + @Test + fun testUpdateBackupInterval() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.backupIntervalValues.isNotEmpty() } + + component.updateBackupInterval("300") + + assertEquals("300", component.state.value.currentBackupIntervalValue) + coVerify { backupScheduler.restart(300) } + } + } + + @Test + fun testOnContentServerChanged() { + runBlocking { + val component = createComponent() + component.state.awaitState { it.contentServerValues.isNotEmpty() } + + component.onContentServerChanged("gitea_value") + + component.state.awaitState { it.currentContentServerValue == "gitea_value" } + assertEquals("gitea_value", component.state.value.currentContentServerValue) + } + } + + @Test + fun testCallbacks() { + runBlocking { + val component = createComponent() + + component.onNavigateBack() + assertEquals(SettingsComponent.Result.NavigateBack, resultReceived) + + component.openDeveloperTools() + assertEquals(SettingsComponent.Result.OpenDeveloperTools, resultReceived) + + component.onMigrationFinished() + assertEquals(SettingsComponent.Result.MigrationFinished, resultReceived) + + component.onLogout() + assertEquals(SettingsComponent.Result.Logout, resultReceived) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/splash/SplashComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/splash/SplashComponentTest.kt new file mode 100644 index 0000000..1c20fae --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/splash/SplashComponentTest.kt @@ -0,0 +1,170 @@ +package org.bibletranslationtools.writer.unit.ui.splash + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.logger.Logger +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.ui.splash.DefaultSplashComponent +import org.bibletranslationtools.writer.ui.splash.SplashComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.usecases.MigrateTranslations +import org.bibletranslationtools.writer.usecases.UpdateApp +import org.bibletranslationtools.writer.utils.RuntimeWrapper +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SplashComponentTest : BaseComponentTest() { + + private val preference: Preference = mockk(relaxed = true) + private val migrateTranslations: MigrateTranslations = mockk() + private val updateApp: UpdateApp = mockk() + + private var resultReceived: SplashComponent.Result? = null + + @Before + fun setUpComponent() { + mockkObject(RuntimeWrapper) + mockkObject(Logger) + + // Default: passes hardware requirements + every { RuntimeWrapper.availableProcessors } returns 4 + every { RuntimeWrapper.maxMemory } returns 1024L * 1024 * 1024 // 1GB + every { Logger.listStacktraces() } returns emptyList() + + // Avoid ClassCastException on generic getPref + every { preference.getPref(any(), any(), any()) } answers { args[1]!! } + + startKoin { + modules( + module { + single { preference } + single { migrateTranslations } + single { updateApp } + } + ) + } + resultReceived = null + } + + private fun createComponent(): DefaultSplashComponent = createComponent { context -> + DefaultSplashComponent( + componentContext = context, + onResult = { resultReceived = it } + ) + } + + private suspend fun awaitResult(timeoutMs: Long = 1000): SplashComponent.Result { + val start = System.currentTimeMillis() + while (resultReceived == null) { + if (System.currentTimeMillis() - start > timeoutMs) { + error("Timeout waiting for SplashComponent result") + } + delay(10) + } + return resultReceived!! + } + + @Test + fun testHardwareWarningTriggered() { + // Mock hardware requirements failing (1 processor) + every { RuntimeWrapper.availableProcessors } returns 1 + every { preference.getPref(Preference.KEY_PREF_CHECK_HARDWARE, true, Boolean::class) } returns true + + val component = createComponent() + + assertTrue(component.state.value.showHardwareWarning) + assertFalse(component.state.value.showMigrationDialog) + } + + @Test + fun testHardwareWarningPassedByPreference() { + // Even if processors are 1, check_hardware is disabled, so warning should not display + every { RuntimeWrapper.availableProcessors } returns 1 + every { preference.getPref(Preference.KEY_PREF_CHECK_HARDWARE, true, Boolean::class) } returns false + every { preference.getPref(Preference.KEY_PREF_MIGRATE_OLD_APP, false, Boolean::class) } returns false + + val component = createComponent() + + assertFalse(component.state.value.showHardwareWarning) + assertTrue(component.state.value.showMigrationDialog) + } + + @Test + fun testMigrationDialogTriggered() { + every { preference.getPref(Preference.KEY_PREF_CHECK_HARDWARE, true, Boolean::class) } returns true + every { preference.getPref(Preference.KEY_PREF_MIGRATE_OLD_APP, false, Boolean::class) } returns false + + val component = createComponent() + + assertFalse(component.state.value.showHardwareWarning) + assertTrue(component.state.value.showMigrationDialog) + } + + @Test + fun testNavigateToCrashReporterIfCrashesExist() { + // Hardware warning passed, migration already shown, stacktraces exist + every { preference.getPref(Preference.KEY_PREF_MIGRATE_OLD_APP, false, Boolean::class) } returns true + every { Logger.listStacktraces() } returns listOf(File("dummy.stacktrace")) + + createComponent() + + assertEquals(SplashComponent.Result.NavigateToCrashReporter, resultReceived) + } + + @Test + fun testUpdateAppAndNavigateToProfileOnSuccess() = runBlocking { + every { preference.getPref(Preference.KEY_PREF_MIGRATE_OLD_APP, false, Boolean::class) } returns true + every { Logger.listStacktraces() } returns emptyList() + + coEvery { updateApp.execute(any()) } returns Unit + + createComponent() + + val result = awaitResult() + assertEquals(SplashComponent.Result.NavigateToProfile, result) + coVerify { updateApp.execute(any()) } + } + + @Test + fun testOnHardwareWarningDismissedAndSaved() { + every { RuntimeWrapper.availableProcessors } returns 1 + every { preference.getPref(Preference.KEY_PREF_CHECK_HARDWARE, true, Boolean::class) } returns true + every { preference.getPref(Preference.KEY_PREF_MIGRATE_OLD_APP, false, Boolean::class) } returns false + + val component = createComponent() + assertTrue(component.state.value.showHardwareWarning) + + component.onHardwareWarningDismissedAndSaved() + + assertFalse(component.state.value.showHardwareWarning) + verify { preference.setPref(Preference.KEY_PREF_CHECK_HARDWARE, false, Boolean::class) } + assertTrue(component.state.value.showMigrationDialog) + } + + @Test + fun testOnMigrationDeclined() = runBlocking { + every { preference.getPref(Preference.KEY_PREF_MIGRATE_OLD_APP, false, Boolean::class) } returns false + coEvery { updateApp.execute(any()) } returns Unit + + val component = createComponent() + assertTrue(component.state.value.showMigrationDialog) + + component.onMigrationDeclined() + + assertFalse(component.state.value.showMigrationDialog) + val result = awaitResult() + assertEquals(SplashComponent.Result.NavigateToProfile, result) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ChunkModeComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ChunkModeComponentTest.kt new file mode 100644 index 0000000..76fb4c4 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ChunkModeComponentTest.kt @@ -0,0 +1,151 @@ +package org.bibletranslationtools.writer.unit.ui.translate + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecontainer.Project +import org.bibletranslationtools.resourcecontainer.ResourceContainer +import org.bibletranslationtools.writer.core.ChapterTranslation +import org.bibletranslationtools.writer.core.FrameTranslation +import org.bibletranslationtools.writer.core.ProjectTranslation +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.TranslationFormat +import org.bibletranslationtools.writer.ui.translate.Footnote +import org.bibletranslationtools.writer.ui.translate.FootnoteAction +import org.bibletranslationtools.writer.ui.translate.TranslateComponent +import org.bibletranslationtools.writer.ui.translate.chunk.DefaultChunkModeComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ChunkModeComponentTest : BaseComponentTest() { + + private val mockContainer = mockk(relaxed = true) + private val mockTarget = mockk(relaxed = true) + + private val sharedState = MutableStateFlow(TranslateComponent.SharedState(resourceContainer = mockContainer)) + + private val mockProject = mockk(relaxed = true) { + every { name } returns "Genesis" + } + + private val mockProjectTranslation = mockk(relaxed = true) { + every { title } returns "Genesis Target" + every { isTitleFinished } returns true + } + + private val mockChapterTranslation = mockk(relaxed = true) { + every { title } returns "Chapter 1" + every { id } returns "01" + } + + private val mockFrameTranslation = mockk(relaxed = true) { + every { id } returns "01" + every { chapterId } returns "01" + every { body } returns "Target Frame Body" + every { format } returns TranslationFormat.USFM + } + + @Before + fun setUpMocks() { + every { mockContainer.contentMimeType } returns "text/usfm" + every { mockContainer.project } returns mockProject + every { mockContainer.chapters() } returns listOf("01") + every { mockContainer.chunks("01") } returns listOf("01") + every { mockContainer.readChunk("01", "01") } returns "In the beginning" + + every { mockTarget.format } returns TranslationFormat.USFM + every { mockTarget.projectTranslation } returns mockProjectTranslation + every { mockTarget.getChapterTranslation("01") } returns mockChapterTranslation + every { mockTarget.getFrameTranslation("01", "01", any()) } returns mockFrameTranslation + } + + private fun createComponent(): DefaultChunkModeComponent = createComponent { context -> + DefaultChunkModeComponent( + componentContext = context, + sharedState = sharedState, + targetTranslation = mockTarget + ) + } + + @Test + fun testInitializationLoadsChunks() { + runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + + assertEquals(1, component.items.value.size) + val item = component.items.value.first() + assertEquals("01-01", item.id) + assertEquals("In the beginning", item.sourceText) + assertEquals("Target Frame Body", item.targetText) + assertTrue(item.sourceOnTop) + } + } + + @Test + fun testOnCardsSwiped() { + runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + component.onCardsSwiped(item, sourceOnTop = false) + assertEquals(false, component.items.value.first().sourceOnTop) + } + } + + @Test + fun testOpenAndClearFootnote() { + val component = createComponent() + val note = Footnote("Test note", "notes", "01-01", 0, 5, 0, FootnoteAction.VIEW) + + component.openFootnote(note) + assertEquals(note, component.state.value.footnote) + + component.clearFootnote() + assertNull(component.state.value.footnote) + } + + @Test + fun testOnItemTextChanged() { + runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + component.onItemTextChanged(item, "New translation text") + + // Wait a brief moment for the coroutine to launch and save the translation + kotlinx.coroutines.delay(100) + + verify { mockTarget.applyFrameTranslation(mockFrameTranslation, "New translation text") } + } + } + + @Test + fun testReopenChunkFlow() { + runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + component.reopenChunk(item) + assertEquals(item, component.state.value.chunkToReopen) + + component.onReopenChunkConfirmed(true) + + // Wait for coroutine to finish + kotlinx.coroutines.delay(100) + + verify { mockTarget.reopenFrame("01", "01") } + assertNull(component.state.value.chunkToReopen) + } + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/LoadingComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/LoadingComponentTest.kt new file mode 100644 index 0000000..f94ce90 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/LoadingComponentTest.kt @@ -0,0 +1,14 @@ +package org.bibletranslationtools.writer.unit.ui.translate + +import org.bibletranslationtools.writer.ui.translate.DefaultLoadingComponent +import org.junit.Test +import kotlin.test.assertNotNull + +class LoadingComponentTest { + + @Test + fun testLoadingComponentInstantiation() { + val component = DefaultLoadingComponent() + assertNotNull(component) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ReadModeComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ReadModeComponentTest.kt new file mode 100644 index 0000000..fe87f95 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ReadModeComponentTest.kt @@ -0,0 +1,114 @@ +package org.bibletranslationtools.writer.unit.ui.translate + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecontainer.Project +import org.bibletranslationtools.resourcecontainer.ResourceContainer +import org.bibletranslationtools.writer.core.ChapterTranslation +import org.bibletranslationtools.writer.core.FrameTranslation +import org.bibletranslationtools.writer.core.ProjectTranslation +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.TranslationFormat +import org.bibletranslationtools.writer.ui.translate.Footnote +import org.bibletranslationtools.writer.ui.translate.FootnoteAction +import org.bibletranslationtools.writer.ui.translate.TranslateComponent +import org.bibletranslationtools.writer.ui.translate.read.DefaultReadModeComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReadModeComponentTest : BaseComponentTest() { + + private val mockContainer = mockk(relaxed = true) + private val mockTarget = mockk(relaxed = true) + + private val sharedState = MutableStateFlow(TranslateComponent.SharedState(resourceContainer = mockContainer)) + + private val mockProject = mockk(relaxed = true) { + every { name } returns "Genesis" + } + + private val mockProjectTranslation = mockk(relaxed = true) { + every { title } returns "Genesis Target" + every { isTitleFinished } returns true + } + + private val mockChapterTranslation = mockk(relaxed = true) { + every { title } returns "Chapter 1" + every { id } returns "01" + } + + private val mockFrameTranslation = mockk(relaxed = true) { + every { id } returns "01" + every { chapterId } returns "01" + every { body } returns "Target Read Body" + every { format } returns TranslationFormat.USFM + } + + @Before + fun setUpMocks() { + every { mockContainer.contentMimeType } returns "text/usfm" + every { mockContainer.project } returns mockProject + every { mockContainer.chapters() } returns listOf("01") + every { mockContainer.chunks("01") } returns listOf("01") + every { mockContainer.readChunk("01", "01") } returns "In the beginning" + + every { mockTarget.format } returns TranslationFormat.USFM + every { mockTarget.projectTranslation } returns mockProjectTranslation + every { mockTarget.getChapterTranslation("01") } returns mockChapterTranslation + every { mockTarget.getFrameTranslation("01", "01", any()) } returns mockFrameTranslation + } + + private fun createComponent(): DefaultReadModeComponent = createComponent { context -> + DefaultReadModeComponent( + componentContext = context, + sharedState = sharedState, + targetTranslation = mockTarget + ) + } + + @Test + fun testInitializationLoadsReadItems() { + runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + + assertEquals(1, component.items.value.size) + val item = component.items.value.first() + assertEquals("01", item.id) // ReadItem uses chapterSlug as ID + assertEquals("In the beginning", item.sourceText.trim()) + assertEquals("Target Read Body", item.targetText.trim()) + assertTrue(item.sourceOnTop) + } + } + + @Test + fun testOnCardsSwiped() { + runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + component.onCardsSwiped(item, sourceOnTop = false) + assertEquals(false, component.items.value.first().sourceOnTop) + } + } + + @Test + fun testOpenAndClearFootnote() { + val component = createComponent() + val note = Footnote("Test note", "notes", "01", 0, 5, 0, FootnoteAction.VIEW) + + component.openFootnote(note) + assertEquals(note, component.state.value.footnote) + + component.clearFootnote() + assertNull(component.state.value.footnote) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ReviewModeComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ReviewModeComponentTest.kt new file mode 100644 index 0000000..c6fcfbe --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/ReviewModeComponentTest.kt @@ -0,0 +1,518 @@ +package org.bibletranslationtools.writer.unit.ui.translate + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.verify +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.runBlocking +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.resourcecontainer.Project +import org.bibletranslationtools.resourcecontainer.ResourceContainer +import org.bibletranslationtools.writer.core.ChapterTranslation +import org.bibletranslationtools.writer.core.ContainerCache +import org.bibletranslationtools.writer.core.FileHistory +import org.bibletranslationtools.writer.core.FrameTranslation +import org.bibletranslationtools.writer.core.ProjectTranslation +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.TranslationFormat +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.rendering.RenderingProvider +import org.bibletranslationtools.writer.rendering.model.NoteStyle +import org.bibletranslationtools.writer.rendering.model.RenderNode +import org.bibletranslationtools.writer.ui.translate.Footnote +import org.bibletranslationtools.writer.ui.translate.FootnoteAction +import org.bibletranslationtools.writer.ui.translate.TranslateComponent +import org.bibletranslationtools.writer.ui.translate.review.DefaultReviewModeComponent +import org.bibletranslationtools.writer.ui.translate.review.Help +import org.bibletranslationtools.writer.ui.translate.review.MarkAllDialogState +import org.bibletranslationtools.writer.ui.translate.review.SearchSubject +import org.bibletranslationtools.writer.ui.translate.review.TargetMode +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.bibletranslationtools.writer.unit.ui.awaitState +import org.bibletranslationtools.writer.usecases.RenderHelps +import org.eclipse.jgit.revwalk.RevCommit +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class ReviewModeComponentTest : BaseComponentTest() { + + private val mockContainer = mockk(relaxed = true) + private val mockTarget = mockk(relaxed = true) + + private val sharedState = MutableStateFlow(TranslateComponent.SharedState(resourceContainer = mockContainer)) + private val eventChannel = Channel(Channel.UNLIMITED) + + private val preference: Preference = mockk(relaxed = true) + private val renderHelps: RenderHelps = mockk(relaxed = true) + private val renderingProvider: RenderingProvider = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + + private val mockProject = mockk(relaxed = true) { + every { name } returns "Genesis" + } + + private val mockProjectTranslation = mockk(relaxed = true) { + every { title } returns "Genesis Target" + every { isTitleFinished } returns true + } + + private val mockChapterTranslation = mockk(relaxed = true) { + every { title } returns "Chapter 1" + every { id } returns "01" + } + + private val mockFrameTranslation = mockk(relaxed = true) { + every { id } returns "01" + every { chapterId } returns "01" + every { body } returns "Target Review Body" + every { format } returns TranslationFormat.USFM + } + + @Before + fun setUpComponent() { + every { mockContainer.contentMimeType } returns "text/usfm" + every { mockContainer.project } returns mockProject + every { mockContainer.chapters() } returns listOf("01") + every { mockContainer.chunks("01") } returns listOf("01") + every { mockContainer.readChunk("01", "01") } returns "In the beginning" + + every { mockTarget.format } returns TranslationFormat.USFM + every { mockTarget.projectTranslation } returns mockProjectTranslation + every { mockTarget.getChapterTranslation("01") } returns mockChapterTranslation + every { mockTarget.getFrameTranslation("01", "01", any()) } returns mockFrameTranslation + // Stub finishFrame to return true so chunk.close() succeeds in markChunkCompleted + every { mockTarget.finishFrame(any(), any()) } returns true + // Stub preference boolean reads so renderHelpContents doesn't ClassCastException + every { preference.getPref(any(), false, Boolean::class) } returns false + every { preference.getPref(any(), true, Boolean::class) } returns false + + startKoin { + modules( + module { + single { preference } + single { renderHelps } + single { renderingProvider } + single { catalogClient } + } + ) + } + } + + private fun createComponent(): DefaultReviewModeComponent = createComponent { context -> + DefaultReviewModeComponent( + componentContext = context, + sharedState = sharedState, + eventSender = eventChannel, + targetTranslation = mockTarget + ) + } + + @Test + fun testInitializationLoadsReviewItems() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + + assertEquals(1, component.items.value.size) + val item = component.items.value.first() + assertEquals("01-01", item.id) + assertEquals("In the beginning", item.sourceText.trim()) + assertEquals(TargetMode.MARKER, item.targetMode) + } + + @Test + fun testOpenAndClearFootnote() { + val component = createComponent() + val note = Footnote("Test note", "notes", "01-01", 0, 5, 0, FootnoteAction.VIEW) + + component.openFootnote(note) + assertEquals(note, component.state.value.footnote) + + component.clearFootnote() + assertNull(component.state.value.footnote) + } + + @Test + fun testToggleEdit() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + component.toggleEdit(item) + + // Since it runs withContext(Dispatchers.IO) inside doToggleEdit, we delay + component.items.awaitState { it.first().targetMode == TargetMode.EDIT } + + assertEquals(TargetMode.EDIT, component.items.value.first().targetMode) + } + + @Test + fun testToggleDoneFlow() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + component.onToggleDone(item) + assertEquals(item, component.state.value.chunkToDone) + + component.onDoneConfirmed(true) + + // Wait for async task to process commit/done action + kotlinx.coroutines.delay(100) + + verify { mockTarget.finishFrame("01", "01") } + assertNull(component.state.value.chunkToDone) + } + + @Test + fun testSearchAndNavigation() = runBlocking { + val component = createComponent() + component.openSearch() + assertNotNull(component.state.value.search) + + component.updateSearchQuery("target") + assertEquals("target", component.state.value.search?.query) + + component.closeSearch() + assertNull(component.state.value.search) + } + + @Test + fun testConflictFiltering() = runBlocking { + val component = createComponent() + component.setConflictFilterOn(true) + assertTrue(component.state.value.conflictFilterOn) + } + + @Test + fun testDeleteFootnote() = runBlocking { + val component = createComponent() + val note = Footnote("Test note", "\\f + Test note\\f*", "01-01", 0, 5, 0, FootnoteAction.VIEW) + + // Stub getFrameTranslation to return text with footnote + every { mockTarget.getFrameTranslation("01", "01", any()) } returns mockk(relaxed = true) { + every { id } returns "01" + every { chapterId } returns "01" + every { body } returns "In the beginning \\f + Test note\\f*" + every { format } returns TranslationFormat.USFM + } + + component.handleResourceChange(mockContainer) + component.items.awaitState { it.isNotEmpty() } + + component.deleteNote(note) + + // Should trigger item text change and save the cleaned version without footnote + verify { mockTarget.getFrameTranslation("01", "01", any()) } + } + + @Test + fun testValidationTranslateFirst() = runBlocking { + // Stub mockFrameTranslation body to be empty to trigger validation error + every { mockFrameTranslation.body } returns "" + + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + component.onToggleDone(item) + assertEquals(item, component.state.value.chunkToDone) + + component.onDoneConfirmed(true) + + // Verify a snackbar message event is sent + val event = eventChannel.receive() + assertTrue(event is TranslateComponent.Event.SnackbarMessage) + } + + @Test + fun testSaveFootnote() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + + // Test inserting footnote (machineReadable is empty) + val insertNote = Footnote( + text = "New footnote text", + machineReadable = "", + chunkId = "01-01", + insertPosition = 5, + action = FootnoteAction.EDIT + ) + component.saveFootnote(insertNote) + + // wait for async saveFootnote + kotlinx.coroutines.delay(100) + verify { mockTarget.applyFrameTranslation(any(), any()) } + + // Test replacing footnote (machineReadable is present) + val replaceNote = Footnote( + text = "Updated footnote text", + machineReadable = "\\f + Old note\\f*", + chunkId = "01-01", + start = 0, + end = 15, + action = FootnoteAction.EDIT + ) + component.saveFootnote(replaceNote) + kotlinx.coroutines.delay(100) + verify(atLeast = 2) { mockTarget.applyFrameTranslation(any(), any()) } + } + + @Test + fun testUndoRedoHistoryNavigation() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + val mockHistory = mockk(relaxed = true) + every { mockTarget.getFrameHistory(any()) } returns mockHistory + every { mockHistory.atHead } returns false + + val mockCommit1 = mockk(relaxed = true) + every { mockHistory.previous } returns mockCommit1 + every { mockHistory.read(mockCommit1) } returns "Previous Translation Version" + + // Toggle edit to load history + component.toggleEdit(item) + component.items.awaitState { it.first().targetMode == TargetMode.EDIT } + + val updatedItem = component.items.value.first() + assertNotNull(updatedItem.fileHistory) + + // Call undo + component.undo(updatedItem) + + // wait for async history navigation + component.items.awaitState { it.first().targetText == "Previous Translation Version" } + assertEquals("Previous Translation Version", component.items.value.first().targetText) + + // Verify redo + val mockCommit2 = mockk(relaxed = true) + every { mockHistory.next } returns mockCommit2 + every { mockHistory.read(mockCommit2) } returns "Next Translation Version" + + component.redo(component.items.value.first()) + component.items.awaitState { it.first().targetText == "Next Translation Version" } + assertEquals("Next Translation Version", component.items.value.first().targetText) + } + + @Test + fun testReopenDoneItem() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + + // Stub mockFrameTranslation finished to return true so item starts as COMPLETE + every { mockFrameTranslation.finished } returns true + + // Trigger resource change to reload and prepare the item as COMPLETE + component.handleResourceChange(mockContainer) + component.items.awaitState { it.first().targetMode == TargetMode.COMPLETE } + + val item = component.items.value.first() + assertEquals(TargetMode.COMPLETE, item.targetMode) + + // Toggle done, which should call updateDoneStatus(item, false) -> reopen + component.onToggleDone(item) + + // wait for background reopen and commit + kotlinx.coroutines.delay(100) + verify { mockTarget.reopenFrame("01", "01") } + verify { mockTarget.commit() } + } + + @Test + fun testMarkAllDoneFlow() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + + // Test toggling and confirming "Mark All Done" + component.toggleMarkAllDone() + assertEquals(MarkAllDialogState.Confirm, component.state.value.markAllDoneState) + + // Stub target text to be non-empty so validation passes, + // and finishFrame returns true (already set in setUp) + every { mockFrameTranslation.body } returns "\\v 1 Some target text" + component.handleResourceChange(mockContainer) + component.items.awaitState { it.first().targetText.isNotEmpty() } + + // Confirm mark all done + component.onMarkAllDoneConfirmed(true) + + // wait for async progress & marking tasks to complete + component.state.awaitState { it.markAllDoneState is MarkAllDialogState.Result } + + val resultState = component.state.value.markAllDoneState as MarkAllDialogState.Result + assertEquals(1, resultState.total) + // marked == 1 when finishFrame returns true and target text is non-empty + verify { mockTarget.finishFrame("01", "01") } + verify { mockTarget.commit() } + + // Test cancel confirmation + component.toggleMarkAllDone() + component.onMarkAllDoneConfirmed(false) + assertNull(component.state.value.markAllDoneState) + } + + @Test + fun testSearchSubjectAndNextPrevMatch() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + + component.openSearch() + + // Set subject to TARGET + component.setSearchSubject(SearchSubject.TARGET) + assertEquals(SearchSubject.TARGET, component.state.value.search?.subject) + + // Update search query matching target text "target" + component.updateSearchQuery("target") + + // Wait for matchingItemIds to update + component.state.awaitState { it.search?.matchingItemIds?.isNotEmpty() == true } + + val searchState = component.state.value.search + assertNotNull(searchState) + assertEquals(listOf("01-01"), searchState.matchingItemIds) + assertEquals(0, searchState.currentMatchIndex) + + // Test nextMatch/prevMatch + component.nextMatch() + assertEquals(0, component.state.value.search?.currentMatchIndex) + + component.prevMatch() + assertEquals(0, component.state.value.search?.currentMatchIndex) + } + + @Test + fun testConflictResolution() = runBlocking { + // Stub mockFrameTranslation to have merge conflict markers + every { mockFrameTranslation.body } returns "<<<<<<< HEAD\nConflicted Option A\n=======\nConflicted Option B\n>>>>>>>\n" + + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + assertTrue(item.hasMergeConflict) + assertEquals(2, item.mergeItems.size) + assertEquals("Conflicted Option A\n", item.mergeItems[0].toString()) + assertEquals("Conflicted Option B\n", item.mergeItems[1].toString()) + + // Select conflict index 0 + component.selectConflict(item, 0) + + // wait for async save/refresh + kotlinx.coroutines.delay(100) + verify { mockTarget.applyFrameTranslation(any(), "Conflicted Option A\n") } + } + + @Test + fun testResourcesAndHelpStateMutations() { + val component = createComponent() + + component.openResources(true) + assertTrue(component.state.value.resourcesOpen) + + component.openResources(false) + assertTrue(!component.state.value.resourcesOpen) + + // Clear help + component.clearHelp() + assertNull(component.state.value.help) + + // Clean url + component.cleanUrl() + assertNull(component.state.value.url) + } + + @Test + fun testOpenIndex() = runBlocking { + mockkObject(ContainerCache) + val mockRc = mockk(relaxed = true) { + every { chapters() } returns listOf("word1") + every { readChunk("word1", "01") } returns "# Title of word1\nbody" + } + every { ContainerCache.get("tw") } returns mockRc + + val component = createComponent() + component.openIndex("tw") + + // Wait for help state to update + component.state.awaitState { it.help is Help.Index } + + val indexHelp = component.state.value.help as Help.Index + assertEquals("tw", indexHelp.rcSlug) + assertEquals(1, indexHelp.words.size) + assertEquals("word1", indexHelp.words.first().slug) + assertEquals(" Title of word1", indexHelp.words.first().title) + } + + @Test + fun testOpenWord() = runBlocking { + mockkObject(ContainerCache) + val mockRc = mockk(relaxed = true) { + every { readChunk("word_slug", "01") } returns "# Word Title\nWord Description Text" + } + every { ContainerCache.get("tw") } returns mockRc + + val component = createComponent() + component.openWord("tw", "word_slug") + + component.state.awaitState { it.help is Help.Words } + + val wordHelp = component.state.value.help as Help.Words + assertEquals("Word Title", wordHelp.title.trim()) + assertEquals("tw", wordHelp.rcSlug) + } + + @Test + fun testDragDropVerse() = runBlocking { + val component = createComponent() + component.items.awaitState { it.isNotEmpty() } + val item = component.items.value.first() + + // Call onDragDropVerse + component.onDragDropVerse( + item = item, + machineReadable = "\\v 2 ", + verseRawStart = 0, + verseRawEnd = 5, + targetRawPosition = 10 + ) + + // Wait for it to process + kotlinx.coroutines.delay(100) + verify { mockTarget.applyFrameTranslation(any(), any()) } + } + + @Test + fun testOnNoteClicked() { + val component = createComponent() + val noteNode = RenderNode.Note( + caller = "+", + passage = "Gen 1:1", + notes = "Test Note Content", + noteStyle = NoteStyle.FOOTNOTE, + machineReadable = "\\f + Test Note Content\\f*", + startPos = 10, + endPos = 20 + ) + + component.onNoteClicked(noteNode, "01-01", FootnoteAction.EDIT) + + val footnote = component.state.value.footnote + assertNotNull(footnote) + assertEquals("Test Note Content", footnote.text) + assertEquals("\\f + Test Note Content\\f*", footnote.machineReadable) + assertEquals("01-01", footnote.chunkId) + assertEquals(10, footnote.start) + assertEquals(20, footnote.end) + assertEquals(FootnoteAction.EDIT, footnote.action) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/TranslateComponentTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/TranslateComponentTest.kt new file mode 100644 index 0000000..60e53f5 --- /dev/null +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/ui/translate/TranslateComponentTest.kt @@ -0,0 +1,143 @@ +package org.bibletranslationtools.writer.unit.ui.translate + +import com.arkivanov.decompose.DefaultComponentContext +import com.arkivanov.essenty.lifecycle.LifecycleRegistry +import com.arkivanov.essenty.lifecycle.resume +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableSharedFlow +import org.bibletranslationtools.resourcecatalog.ResourceCatalogClient +import org.bibletranslationtools.writer.Platform +import org.bibletranslationtools.writer.core.TargetTranslation +import org.bibletranslationtools.writer.core.TranslationViewMode +import org.bibletranslationtools.writer.core.Translator +import org.bibletranslationtools.writer.data.Preference +import org.bibletranslationtools.writer.ui.navigation.RootComponent +import org.bibletranslationtools.writer.ui.translate.DefaultTranslateComponent +import org.bibletranslationtools.writer.ui.translate.TranslateComponent +import org.bibletranslationtools.writer.unit.ui.BaseComponentTest +import org.junit.Before +import org.junit.Test +import org.koin.core.context.startKoin +import org.koin.dsl.module +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TranslateComponentTest : BaseComponentTest() { + + private val translator: Translator = mockk(relaxed = true) + private val preference: Preference = mockk(relaxed = true) + private val catalogClient: ResourceCatalogClient = mockk(relaxed = true) + private val platform: Platform = mockk(relaxed = true) + + private val sharedFlow = MutableSharedFlow() + private var resultReceived: TranslateComponent.Result? = null + + private val mockTarget = mockk(relaxed = true) { + every { id } returns "target-1" + every { targetLanguage.slug } returns "en" + every { targetLanguageName } returns "English" + every { projectId } returns "gen" + every { sourceTranslations } returns listOf("reg") + every { numTranslated } returns 5 + } + + @Before + fun setUpComponent() { + startKoin { + modules( + module { + single { translator } + single { preference } + single { catalogClient } + single { platform } + } + ) + } + resultReceived = null + } + + private fun createComponent( + translationId: String = "target-1", + initialViewMode: TranslationViewMode? = null, + autoResume: Boolean = true + ): Pair { + val lifecycle = LifecycleRegistry() + val context = DefaultComponentContext(lifecycle) + val component = DefaultTranslateComponent( + componentContext = context, + translationId = translationId, + initialViewMode = initialViewMode, + conflictFilterOn = false, + sharedFlow = sharedFlow, + onResult = { resultReceived = it } + ) + if (autoResume) { + lifecycle.resume() + } + return component to lifecycle + } + + @Test + fun testInitializationTranslationNotFound() { + coEvery { translator.getTargetTranslation("unknown") } returns null + + createComponent(translationId = "unknown", autoResume = false) + + assertTrue(resultReceived is TranslateComponent.Result.Error) + } + + @Test + fun testInitializationSuccess() { + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + every { preference.getLastViewMode("target-1") } returns TranslationViewMode.CHUNK + every { preference.getOpenSourceTranslations("target-1") } returns listOf("reg") + + val (component, _) = createComponent() + + // Active child should be Chunk mode + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is TranslateComponent.Child.Chunk) + assertEquals(TranslationViewMode.CHUNK, component.currentViewMode.value) + } + + @Test + fun testOpenReadMode() { + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + every { preference.getLastViewMode("target-1") } returns TranslationViewMode.CHUNK + + val (component, _) = createComponent() + component.openReadMode() + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is TranslateComponent.Child.Read) + assertEquals(TranslationViewMode.READ, component.currentViewMode.value) + } + + @Test + fun testOpenChunkMode() { + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + every { preference.getLastViewMode("target-1") } returns TranslationViewMode.READ + + val (component, _) = createComponent() + component.openChunkMode() + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is TranslateComponent.Child.Chunk) + assertEquals(TranslationViewMode.CHUNK, component.currentViewMode.value) + } + + @Test + fun testOpenReviewMode() { + coEvery { translator.getTargetTranslation("target-1") } returns mockTarget + every { preference.getLastViewMode("target-1") } returns TranslationViewMode.CHUNK + + val (component, _) = createComponent() + component.openReviewMode(conflictFilterOn = true) + + val activeChild = component.stack.value.active.instance + assertTrue(activeChild is TranslateComponent.Child.Review) + assertEquals(TranslationViewMode.REVIEW, component.currentViewMode.value) + } +} diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/usecases/PushTargetTranslationTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/usecases/PushTargetTranslationTest.kt index 4667558..658d1a6 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/usecases/PushTargetTranslationTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/usecases/PushTargetTranslationTest.kt @@ -60,6 +60,7 @@ class PushTargetTranslationTest { every { repository.sshUrl }.returns("ssh://repo.git") coEvery { getRepository.execute(targetTranslation, onProgress) }.returns(repository) + every { targetTranslation.id }.returns("test") every { targetTranslation.commitSync() }.returns(true) every { targetTranslation.repo }.returns(repo) every { repo.git }.returns(git) diff --git a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/usecases/UpdateAppTest.kt b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/usecases/UpdateAppTest.kt index 3093395..1a65776 100644 --- a/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/usecases/UpdateAppTest.kt +++ b/shared/src/commonTest/kotlin/org/bibletranslationtools/writer/unit/usecases/UpdateAppTest.kt @@ -240,6 +240,7 @@ class UpdateAppTest { val targetTranslation: TargetTranslation = mockk { every { id }.returns("aa_mrk_text_ulb") + every { unlockRepo() }.returns(true) } coEvery { translator.getTargetTranslations() }.returns(listOf(targetTranslation))