diff --git a/app/src/main/java/be/scri/helpers/StringUtils.kt b/app/src/main/java/be/scri/helpers/StringUtils.kt new file mode 100644 index 00000000..541bf7b8 --- /dev/null +++ b/app/src/main/java/be/scri/helpers/StringUtils.kt @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers + +/** + * Utility object for handling string-related operations. + */ +object StringUtils { + /** + * Checks if a word is capitalized (i.e., starts with an uppercase letter). + * @param word The word to check. + * @return `true` if the word is capitalized, `false` otherwise. + */ + fun isWordCapitalized(word: String): Boolean { + if (word.isEmpty()) return false + return word[0].isUpperCase() + } +} diff --git a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt index 4500e637..b5bff7f1 100644 --- a/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt +++ b/app/src/main/java/be/scri/helpers/data/PluralFormsManager.kt @@ -4,6 +4,7 @@ package be.scri.helpers.data import DataContract import android.database.sqlite.SQLiteDatabase import be.scri.helpers.DatabaseFileManager +import be.scri.helpers.StringUtils.isWordCapitalized /** * Manages and queries plural forms of words from the database. @@ -49,9 +50,23 @@ class PluralFormsManager( val pluralCol = numbers.values.firstOrNull() if (singularCol != null && pluralCol != null) { - fileManager.getLanguageDatabase(language)?.use { db -> - querySpecificPlural(db, singularCol, pluralCol, noun) - } + val wasCapitalized = isWordCapitalized(noun) + val lowerNoun = noun.lowercase() + + val result = + fileManager.getLanguageDatabase(language)?.use { db -> + querySpecificPlural(db, singularCol, pluralCol, lowerNoun) + } ?: emptyMap() + + if (result.isEmpty()) return emptyMap() + + val (singular, plural) = result.entries.first() + + val singularOut = if (wasCapitalized) singular.replaceFirstChar { it.uppercase() } else singular + + val pluralOut = if (wasCapitalized) plural?.replaceFirstChar { it.uppercase() } else plural + + return mapOf(singularOut to pluralOut) } else { null } diff --git a/app/src/main/java/be/scri/helpers/data/TranslationDataManager.kt b/app/src/main/java/be/scri/helpers/data/TranslationDataManager.kt index 5c148eac..15897c82 100644 --- a/app/src/main/java/be/scri/helpers/data/TranslationDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/TranslationDataManager.kt @@ -5,6 +5,7 @@ import android.content.Context import android.database.sqlite.SQLiteDatabase import be.scri.helpers.DatabaseFileManager import be.scri.helpers.PreferencesHelper +import be.scri.helpers.StringUtils.isWordCapitalized /** * Manages translations from a local SQLite database. @@ -47,9 +48,44 @@ class TranslationDataManager( val sourceTable = generateLanguageNameForISOCode(sourceCode) - return fileManager.getTranslationDatabase()?.use { db -> - queryForTranslation(db, sourceTable, destCode, word) - } ?: "" + val isGerman = sourceCode == "de" + + val db = fileManager.getTranslationDatabase() ?: return "" + + val variants = mutableListOf() + + // Try exact match of input. + variants.add(word) + + // Add lowercase variants. + if (isGerman || isWordCapitalized(word)) { + variants.add(word.lowercase()) + } + + // Note: In German canonical noun is capitalization ("buch" → "Buch"). + if (isGerman) { + val canonical = word.lowercase().replaceFirstChar { it.uppercase() } + variants.add(canonical) + } + + db.use { database -> + for (variant in variants) { + val result = queryForTranslation(database, sourceTable, destCode, variant) + + if (result.isNotEmpty()) { + // Non-German rule: + // If the user typed a capitalized word, but the match happened using the lowercase version, + // then re-capitalize the translated result. + if (!isGerman && isWordCapitalized(word) && variant == word.lowercase()) { + return result.replaceFirstChar { it.uppercase() } + } + + return result + } + } + } + + return "" } /** diff --git a/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt index 739c2f2d..42b771c7 100644 --- a/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/main/java/be/scri/services/GeneralKeyboardIME.kt @@ -2291,6 +2291,7 @@ abstract class GeneralKeyboardIME( * @param rawInput The verb entered in the command bar. */ private fun handleConjugateState(rawInput: String) { + val searchInput = rawInput.lowercase() currentVerbForConjugation = rawInput val languageAlias = getLanguageAlias(language) @@ -2298,7 +2299,7 @@ abstract class GeneralKeyboardIME( dbManagers.conjugateDataManager.getTheConjugateLabels( languageAlias, dataContract, - rawInput, + searchInput, ) conjugateOutput = @@ -2311,7 +2312,7 @@ abstract class GeneralKeyboardIME( conjugateLabels = dbManagers.conjugateDataManager.extractConjugateHeadings( dataContract, - rawInput, + searchInput, ) currentState = diff --git a/app/src/test/kotlin/be/scri/helpers/data/TranslationDataManagerTest.kt b/app/src/test/kotlin/be/scri/helpers/data/TranslationDataManagerTest.kt new file mode 100644 index 00000000..cd097ae0 --- /dev/null +++ b/app/src/test/kotlin/be/scri/helpers/data/TranslationDataManagerTest.kt @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import be.scri.helpers.DatabaseFileManager +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class TranslationDataManagerTest { + private lateinit var context: Context + private lateinit var fileManager: DatabaseFileManager + private lateinit var db: SQLiteDatabase + private lateinit var manager: TranslationDataManager + + @BeforeEach + fun setup() { + context = mockk(relaxed = true) + fileManager = mockk(relaxed = true) + db = mockk(relaxed = true) + + manager = TranslationDataManager(context, fileManager) + } + + private fun createCursorWithResult(result: String): Cursor = + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { getColumnIndexOrThrow(any()) } returns 0 + every { getString(0) } returns result + } + + private fun createEmptyCursor(): Cursor = + mockk(relaxed = true) { + every { moveToFirst() } returns false + } + + @Test + fun `finds translation using lowercase variant when exact capitalized match fails`() { + every { fileManager.getTranslationDatabase() } returns db + + // First query (exact "Book") fails, second query (lowercase "book") succeeds. + every { db.rawQuery(any(), any()) } returnsMany + listOf( + createEmptyCursor(), + createCursorWithResult("livre"), + ) + + val result = manager.getTranslationDataForAWord("en" to "fr", "Book") + + // Should recapitalize the result since input was capitalized. + assertEquals("Livre", result) + } + + @Test + fun `does not recapitalize when input is lowercase`() { + every { fileManager.getTranslationDatabase() } returns db + + // Exact match succeeds immediately. + every { db.rawQuery(any(), any()) } returns createCursorWithResult("livre") + + val result = manager.getTranslationDataForAWord("en" to "fr", "book") + + // Input was lowercase, so output stays lowercase. + assertEquals("livre", result) + } + + @Test + fun `returns empty string when no variant matches`() { + every { fileManager.getTranslationDatabase() } returns db + + // All queries fail. + every { db.rawQuery(any(), any()) } returns createEmptyCursor() + + val result = manager.getTranslationDataForAWord("en" to "fr", "nonexistent") + + assertEquals("", result) + } + + @Test + fun `German capitalized input matches exact`() { + every { fileManager.getTranslationDatabase() } returns db + + // User types "Buch", database has "Buch" - direct match. + every { db.rawQuery(any(), any()) } returns createCursorWithResult("book") + + val result = manager.getTranslationDataForAWord("de" to "en", "Buch") + + assertEquals("book", result) + } + + @Test + fun `German source tries canonical capitalization as fallback`() { + every { fileManager.getTranslationDatabase() } returns db + + // Simulate: exact "buch" fails, lowercase "buch" fails, canonical "Buch" succeeds. + every { db.rawQuery(any(), any()) } returnsMany + listOf( + createEmptyCursor(), + createEmptyCursor(), + createCursorWithResult("book"), + ) + + val result = manager.getTranslationDataForAWord("de" to "en", "buch") + + assertEquals("book", result) + } + + @Test + fun `German capitalized verb finds translation via lowercase fallback`() { + every { fileManager.getTranslationDatabase() } returns db + + // User types "Laufen" (capitalized verb), but database has "laufen" (lowercase). + every { db.rawQuery(any(), any()) } returnsMany + listOf( + createEmptyCursor(), + createCursorWithResult("run"), + ) + + val result = manager.getTranslationDataForAWord("de" to "en", "Laufen") + + // German doesn't recapitalize, returns result as-is. + assertEquals("run", result) + } + + @Test + fun `returns original word when source and destination are the same`() { + // No database call should happen. + val result = manager.getTranslationDataForAWord("en" to "en", "Book") + + assertEquals("Book", result) + verify(exactly = 0) { fileManager.getTranslationDatabase() } + } + + @Test + fun `returns empty string when database is unavailable`() { + every { fileManager.getTranslationDatabase() } returns null + + val result = manager.getTranslationDataForAWord("en" to "fr", "book") + + assertEquals("", result) + } + + @Test + fun `returns original word when source language is null`() { + val result = manager.getTranslationDataForAWord(null to "fr", "Book") + + assertEquals("Book", result) + } + + @Test + fun `returns original word when destination language is null`() { + val result = manager.getTranslationDataForAWord("en" to null, "Book") + + assertEquals("Book", result) + } +}