From 5b5f54fc52e5bae53c5065ef949e56f8ffe1cbb1 Mon Sep 17 00:00:00 2001 From: prince-0408 <66916296+prince-0408@users.noreply.github.com> Date: Wed, 28 Oct 2026 12:45:04 +0530 Subject: [PATCH 1/3] fix: resolve "Not in Wikidata" errors and eliminate redundant sequential SQLite queries (#598, #738) --- CHANGELOG.md | 1 + .../be/scri/helpers/DatabaseFileManager.kt | 10 +- .../scri/helpers/data/ConjugateDataManager.kt | 380 +++++++++++------- .../be/scri/helpers/data/SQLiteExtensions.kt | 17 + .../ui/screens/ConjugationSelectionScreen.kt | 23 +- .../helpers/data/ConjugateDataManagerTest.kt | 271 +++++++++++++ 6 files changed, 539 insertions(+), 163 deletions(-) create mode 100644 app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ba73ab4..1de413cf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -91,6 +91,7 @@ Emojis for the following are chosen based on [gitmoji](https://gitmoji.dev/). - Downloading or checking for language data updates no longer crashes the app when the server returns a malformed timestamp ([#700](https://github.com/scribe-org/Scribe-Android/issues/700)). - Native dictionaries are re-extracted after an app update so autosuggestions no longer use dictionaries from a previous install ([#701](https://github.com/scribe-org/Scribe-Android/issues/701)). - Tutorial practice text is now preserved across configuration changes such as device rotation ([#736](https://github.com/scribe-org/Scribe-Android/issues/736)). +- Verb conjugation lookups in the keyboard now support case-insensitive matching, inflected forms, and fallback to downloaded language databases, resolving "Not in Wikidata" errors ([#598](https://github.com/scribe-org/Scribe-Android/issues/598), [#738](https://github.com/scribe-org/Scribe-Android/issues/738)). ### 🔒 Security diff --git a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt index c9f98e8a9..a70ceeb54 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt @@ -36,7 +36,8 @@ class DatabaseFileManager( * @return An open, read-only [SQLiteDatabase] instance, or `null` on failure. */ fun getLanguageDatabase(language: String): SQLiteDatabase? { - val dbName = "${language}LanguageData.sqlite" + val normalizedLang = language.uppercase() + val dbName = "${normalizedLang}LanguageData.sqlite" val dbFile = context.getDatabasePath(dbName) if (!dbFile.exists()) { @@ -72,10 +73,15 @@ class DatabaseFileManager( * @return An open, read-only [SQLiteDatabase] instance, or `null` on failure. */ fun getConjugateDatabase(language: String): SQLiteDatabase? { - val dbName = "${language}ConjugateData.sqlite" + val normalizedLang = language.uppercase() + val dbName = "${normalizedLang}ConjugateData.sqlite" val dbFile = context.getDatabasePath(dbName) if (!dbFile.exists()) { + val languageDb = getLanguageDatabase(normalizedLang) + if (languageDb != null) { + return languageDb + } Log.w(TAG, "Conjugate database $dbName not found. User needs to download conjugate data first") return null } diff --git a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt index 114a72680..c33886e00 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -33,22 +33,75 @@ class ConjugateDataManager( yamlData: DataContract?, word: String, ): MutableMap>>? { - val finalOutput: MutableMap>> = mutableMapOf() - yamlData?.verbs?.conjugations?.values?.forEach { tenseGroup -> - val conjugateForms: MutableMap> = mutableMapOf() - tenseGroup.tenses.values.forEach { conjugationCategory -> - val forms = - conjugationCategory.tenseForms.values.map { form -> - getTheValueForTheConjugateWord(word.lowercase(), form.value, language) - } - conjugateForms[conjugationCategory.tenseTitle] = forms + val db = fileManager.getConjugateDatabase(language) ?: return null + return db.use { database -> + if (!database.tableExists("verbs")) { + return@use null + } + + val rowData = getVerbRowData(database, word) ?: return@use null + + val finalOutput: MutableMap>> = mutableMapOf() + yamlData?.verbs?.conjugations?.values?.forEach { tenseGroup -> + val conjugateForms: MutableMap> = mutableMapOf() + tenseGroup.tenses.values.forEach { conjugationCategory -> + val forms = + conjugationCategory.tenseForms.values.map { form -> + resolveConjugatedValue(rowData, form.value, database) + } + conjugateForms[conjugationCategory.tenseTitle] = forms + } + finalOutput[tenseGroup.sectionTitle] = conjugateForms + } + if (finalOutput.isEmpty() || finalOutput.values.all { it.isEmpty() || it.values.all { forms -> forms.all { it.isEmpty() } } }) { + null + } else { + finalOutput } - finalOutput[tenseGroup.sectionTitle] = conjugateForms } - return if (finalOutput.isEmpty() || finalOutput.values.all { it.isEmpty() || it.values.all { forms -> forms.all { it.isEmpty() } } }) { - null - } else { - finalOutput + } + + /** + * Retrieves structured conjugation data mapped to form labels for UI display. + * + * @param language The language code (e.g., "EN", "DE"). + * @param yamlData The data contract defining the conjugation structure. + * @param word The verb to conjugate. + * + * @return A structured map of tense groups to categories and (label, form) pairs, or null if not found. + */ + fun getStructuredConjugations( + language: String, + yamlData: DataContract?, + word: String, + ): Map>>>? { + val db = fileManager.getConjugateDatabase(language) ?: return null + return db.use { database -> + if (!database.tableExists("verbs")) { + return@use null + } + + val rowData = getVerbRowData(database, word) ?: return@use null + + val structuredData = mutableMapOf>>>() + yamlData?.verbs?.conjugations?.values?.forEach { tenseGroup -> + val categories = mutableMapOf>>() + tenseGroup.tenses.values.forEach { conjugationCategory -> + val pairs = + conjugationCategory.tenseForms.values + .map { form -> + val resolvedForm = resolveConjugatedValue(rowData, form.value, database) + form.label to resolvedForm + }.filter { it.second.isNotEmpty() } + if (pairs.isNotEmpty()) { + categories[conjugationCategory.tenseTitle] = pairs + } + } + if (categories.isNotEmpty()) { + structuredData[tenseGroup.sectionTitle] = categories + } + } + if (structuredData.isEmpty()) null else structuredData } } @@ -97,75 +150,144 @@ class ConjugateDataManager( return "" } - val columnName = db.getInfinitiveColumnName() ?: return "" - - getVerbCursor(db, word, columnName)?.use { cursor -> - getConjugatedValueFromCursor(cursor, form, language) - } + val rowData = getVerbRowData(db, word) ?: return "" + resolveConjugatedValue(rowData, form, db) } ?: "" } - private fun getColumnIndexWithFallback( - cursor: Cursor, - columnName: String, - ): Int { - var idx = cursor.getColumnIndex(columnName) - if (idx != -1) return idx + /** + * Retrieves the verb row from the database using case-insensitive matching, + * prefix-stripping fallback, and inflected form fallback. + */ + private fun getVerbRowData( + db: SQLiteDatabase, + word: String, + ): Map? { + val cleanWord = word.trim() + if (cleanWord.isEmpty()) return null - val fallbacks = - listOf( - "combined" + columnName.replaceFirstChar { it.uppercase() }, - columnName.replace("presentParticiple", "combinedPresentParticiple"), - columnName.replace("pastParticiple", "combinedPastParticiple"), - ) - for (fallback in fallbacks) { - idx = cursor.getColumnIndex(fallback) - if (idx != -1) return idx + val candidateColumns = + listOf("infinitive", "verb", "activeInfinitive", "simplePresent") + .filter { db.columnExists("verbs", it) } + + // 1. Primary case-insensitive search across candidate infinitive columns + for (col in candidateColumns) { + val cursor = + db.rawQuery( + "SELECT * FROM verbs WHERE LOWER(${SqlIdentifier.quote(col)}) = LOWER(?) LIMIT 1", + arrayOf(cleanWord), + ) + val row = cursorToMap(cursor) + if (row != null) return row + } + + // 2. Fallback: try prefix-stripped form if user typed e.g. "to go", "se laver", "sich freuen" + val stripped = + when { + cleanWord.startsWith("to ", ignoreCase = true) -> cleanWord.substring(3).trim() + cleanWord.startsWith("se ", ignoreCase = true) -> cleanWord.substring(3).trim() + cleanWord.startsWith("sich ", ignoreCase = true) -> cleanWord.substring(5).trim() + cleanWord.startsWith("s'", ignoreCase = true) -> cleanWord.substring(2).trim() + else -> null + } + if (!stripped.isNullOrEmpty()) { + for (col in candidateColumns) { + val cursor = + db.rawQuery( + "SELECT * FROM verbs WHERE LOWER(${SqlIdentifier.quote(col)}) = LOWER(?) LIMIT 1", + arrayOf(stripped), + ) + val row = cursorToMap(cursor) + if (row != null) return row + } } - throw IllegalArgumentException("column '$columnName' does not exist. Available columns: " + cursor.columnNames.joinToString(", ")) + // 3. Fallback: search if user entered an inflected form in any verb column + return findRowByInflectedForm(db, cleanWord) } /** - * Extracts a conjugated value from a database cursor for a given form. - * It handles both simple column lookups and complex forms that require parsing. - * - * @param cursor The database cursor positioned at the correct row for the verb. - * @param form The form identifier, which can be a simple column name or a complex string. - * - * @return The conjugated value, or an empty string on failure. + * Searches across all columns of the `verbs` table to find the matching verb row for an inflected form. */ - private fun getConjugatedValueFromCursor( - cursor: Cursor, - form: String, - language: String, - ): String = - if (form.contains("[") || form.contains("{")) { - parseComplexForm(cursor, form, language) - } else { - try { - cursor.getString(getColumnIndexWithFallback(cursor, form)) - } catch (e: IllegalArgumentException) { - Log.e("ConjugateDataManager", "Simple form column not found: '$form'", e) - "" + private fun findRowByInflectedForm( + db: SQLiteDatabase, + word: String, + ): Map? { + val allColumns = db.getTableColumns("verbs") + val validColumns = + allColumns.filter { + it != "id" && it != "wdLexemeId" && it != "auxiliaryVerb" && SqlIdentifier.isValid(it) + } + if (validColumns.isEmpty()) return null + + val conditions = + validColumns.joinToString(" OR ") { + "LOWER(${SqlIdentifier.quote(it)}) = LOWER(?)" + } + val args = Array(validColumns.size) { word } + return try { + val cursor = db.rawQuery("SELECT * FROM verbs WHERE $conditions LIMIT 1", args) + cursorToMap(cursor) + } catch (e: SQLiteException) { + Log.w("ConjugateDataManager", "Fallback inflected search failed: ${e.message}") + null + } + } + + private fun cursorToMap(cursor: Cursor): Map? = + cursor.use { + if (!it.moveToFirst()) return@use null + val map = mutableMapOf() + val columnNames = it.columnNames + for (i in columnNames.indices) { + map[columnNames[i]] = it.getString(i) ?: "" } + map + } + + /** + * Resolves a conjugated form value from the cached row data, supporting complex auxiliary parsing. + */ + private fun resolveConjugatedValue( + rowData: Map, + form: String?, + db: SQLiteDatabase, + ): String { + if (form.isNullOrEmpty()) return "" + return if (form.contains("[") || form.contains("{")) { + parseComplexFormFromRow(rowData, form, db) + } else { + getValueWithFallback(rowData, form) + } + } + + private fun getValueWithFallback( + rowData: Map, + form: String, + ): String { + rowData[form]?.let { if (it.isNotEmpty()) return it } + + val fallbacks = + listOf( + "combined" + form.replaceFirstChar { it.uppercase() }, + form.replace("presentParticiple", "combinedPresentParticiple"), + form.replace("pastParticiple", "combinedPastParticiple"), + ) + for (fallback in fallbacks) { + val value = rowData[fallback] + if (!value.isNullOrEmpty()) return value } + return rowData[form] ?: "" + } /** * Parses a complex conjugation form that includes an auxiliary part in brackets, - * such as "[have] past_participle" (literal words) or - * "{indicativePresentFirstPersonSingular auxiliaryVerb} pastParticiple" (column lookup). - * It combines the auxiliary word with the value from the specified database column. - * - * @param cursor The database cursor positioned at the correct row. - * @param form The complex form string to parse. - * - * @return The combined string (e.g., "have walked"), or an empty string on failure. + * reusing the existing open database connection. */ - private fun parseComplexForm( - cursor: Cursor, + private fun parseComplexFormFromRow( + rowData: Map, form: String, - language: String, + db: SQLiteDatabase, ): String { val bracketRegex = Regex("""[\[{](.*?)[]}]""") val match = bracketRegex.find(form) ?: return "" @@ -173,94 +295,70 @@ class ConjugateDataManager( val auxiliaryWords = match.groupValues[1] val dbColumnName = form.replace(bracketRegex, "").trim() - return try { - val verbPart = cursor.getString(getColumnIndexWithFallback(cursor, dbColumnName)) + val verbPart = getValueWithFallback(rowData, dbColumnName) - // Try to handle it as a dynamic lookup (German style: [form auxiliary_column]). - try { - val words = auxiliaryWords.split(Regex("\\s+")) - // If it's a single word and not a column, this might throw, which is fine (English case). - // If it's multiple words, the last one is the auxiliary verb column (e.g. "auxiliaryVerb"). - val auxColumn = words.last() - - // Check if this column exists and get the auxiliary verb (e.g. "haben"). - val auxVerbIndex = cursor.getColumnIndex(auxColumn) - require(auxVerbIndex != -1) { "Column $auxColumn not found" } - val verbType = cursor.getString(auxVerbIndex) + // Try dynamic auxiliary lookup (e.g. German style: [form auxiliary_column]) + return try { + val words = auxiliaryWords.split(Regex("\\s+")) + val auxColumn = words.last() - val targetForm = words.first() + val verbType = rowData[auxColumn] + if (verbType.isNullOrEmpty()) { + return "$auxiliaryWords $verbPart".trim() + } - require(SqlIdentifier.isValid(targetForm)) { "Invalid column name: $targetForm" } + val targetForm = words.first() + if (!SqlIdentifier.isValid(targetForm)) { + return "$auxiliaryWords $verbPart".trim() + } - val db = fileManager.getConjugateDatabase(language = language) - var auxResult = "" + val quotedTarget = SqlIdentifier.quote(targetForm) + var auxResult = "" - val auxCursor = - db?.rawQuery( - "SELECT $targetForm FROM verbs WHERE wdLexemeId = ?", + try { + db + .rawQuery( + "SELECT $quotedTarget FROM verbs WHERE wdLexemeId = ? LIMIT 1", arrayOf(verbType), - ) + ).use { auxCursor -> + if (auxCursor.moveToFirst()) { + auxResult = auxCursor.getString(0) ?: "" + } + } + } catch (e: SQLiteException) { + Log.w("ConjugateDataManager", "Auxiliary query by wdLexemeId failed: ${e.message}") + } - if (auxCursor?.moveToFirst() == true) { - auxResult = auxCursor.getString(0) - } else { - // Fallback case: Maybe it stores the infinitive. - auxCursor?.close() - val auxCursor2 = - db?.rawQuery( - "SELECT $targetForm FROM verbs WHERE infinitive = ?", + if (auxResult.isEmpty()) { + try { + db + .rawQuery( + "SELECT $quotedTarget FROM verbs WHERE infinitive = ? LIMIT 1", arrayOf(verbType), - ) - if (auxCursor2?.moveToFirst() == true) { - auxResult = auxCursor2.getString(0) - } - auxCursor2?.close() + ).use { auxCursor2 -> + if (auxCursor2.moveToFirst()) { + auxResult = auxCursor2.getString(0) ?: "" + } + } + } catch (e: SQLiteException) { + Log.w("ConjugateDataManager", "Auxiliary query by infinitive failed: ${e.message}") } - auxCursor?.close() + } - if (auxResult.isNotEmpty()) { - "$auxResult $verbPart".trim() - } else { - "$auxiliaryWords $verbPart".trim() - } - } catch (e: IllegalArgumentException) { - Log.w("ConjugateDataManager", "Dynamic lookup failed: ${e.message}") - "$auxiliaryWords $verbPart".trim() - } catch (e: NoSuchElementException) { - Log.w("ConjugateDataManager", "Dynamic lookup failed: ${e.message}") - "$auxiliaryWords $verbPart".trim() - } catch (e: SQLiteException) { - Log.e("ConjugateDataManager", "Database error in dynamic lookup", e) + if (auxResult.isNotEmpty()) { + "$auxResult $verbPart".trim() + } else { "$auxiliaryWords $verbPart".trim() } } catch (e: IllegalArgumentException) { - Log.e("ConjugateDataManager", "Column '$dbColumnName' not found", e) - "" - } - } - - /** - * Creates and returns a database cursor pointing to the requested verb's data row. - * - * @param db The SQLite database instance to query. - * @param word The verb to search for. - * @param columnName The name of the infinitive/verb column. - * - * @return A [Cursor] positioned at the verb's row, or null if the verb is not found. - * The caller is responsible for closing the cursor. - */ - private fun getVerbCursor( - db: SQLiteDatabase, - word: String, - columnName: String, - ): Cursor? { - val query = "SELECT * FROM verbs WHERE $columnName = ?" - val cursor = db.rawQuery(query, arrayOf(word)) - return if (cursor.moveToFirst()) { - cursor - } else { - cursor.close() - null + Log.w("ConjugateDataManager", "Dynamic lookup failed: ${e.message}") + "$auxiliaryWords $verbPart".trim() + } catch (e: NoSuchElementException) { + Log.w("ConjugateDataManager", "Dynamic lookup failed: ${e.message}") + "$auxiliaryWords $verbPart".trim() + } catch (e: SQLiteException) { + Log.e("ConjugateDataManager", "Database error in dynamic lookup", e) + "$auxiliaryWords $verbPart".trim() } } } diff --git a/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt b/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt index 587c97edd..1798a8bd8 100644 --- a/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt +++ b/app/src/main/java/be/scri/helpers/data/SQLiteExtensions.kt @@ -3,6 +3,7 @@ package be.scri.helpers.data import android.database.sqlite.SQLiteDatabase +import be.scri.data.remote.SqlIdentifier fun SQLiteDatabase.tableExists(tableName: String): Boolean = rawQuery( @@ -26,6 +27,22 @@ fun SQLiteDatabase.columnExists( false } +fun SQLiteDatabase.getTableColumns(tableName: String): List { + if (!SqlIdentifier.isValid(tableName)) return emptyList() + return rawQuery("PRAGMA table_info(${SqlIdentifier.quote(tableName)})", null).use { cursor -> + val columns = mutableListOf() + if (cursor.moveToFirst()) { + val nameIndex = cursor.getColumnIndex("name") + if (nameIndex != -1) { + do { + cursor.getString(nameIndex)?.let { columns.add(it) } + } while (cursor.moveToNext()) + } + } + columns + } +} + fun SQLiteDatabase.getInfinitiveColumnName(): String? { val candidates = listOf("infinitive", "verb", "activeInfinitive", "simplePresent") for (candidate in candidates) { diff --git a/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt b/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt index edc46c197..42643dde3 100644 --- a/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -88,31 +89,13 @@ fun ConjugationSelectionScreen( val fileManager = DatabaseFileManager(context) val manager = ConjugateDataManager(fileManager) - val structuredData = mutableMapOf>>>() - contract.verbs.conjugations.values.forEach { tenseGroup -> - val categories = mutableMapOf>>() - tenseGroup.tenses.values.forEach { conjugationCategory -> - val pairs = - conjugationCategory.tenseForms.values - .map { form -> - val resolvedForm = manager.getTheValueForTheConjugateWord(verb.lowercase(), form.value, languageAlias) - form.label to resolvedForm - }.filter { it.second.isNotEmpty() } - if (pairs.isNotEmpty()) { - categories[conjugationCategory.tenseTitle] = pairs - } - } - if (categories.isNotEmpty()) { - structuredData[tenseGroup.sectionTitle] = categories - } - } - conjugationData = structuredData + conjugationData = manager.getStructuredConjugations(languageAlias, contract, verb) } } } var expanded by remember { mutableStateOf(false) } - var selectedTenseGroup by remember { mutableStateOf(null) } + var selectedTenseGroup by rememberSaveable { mutableStateOf(null) } val tenseGroupsList = conjugationData?.keys?.toList() ?: emptyList() ScribeBaseScreen( diff --git a/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt b/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt new file mode 100644 index 000000000..ac7530d04 --- /dev/null +++ b/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt @@ -0,0 +1,271 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.data + +import ConjugationCategory +import DataContract +import Genders +import Nouns +import TenseForm +import TenseGroup +import Translations +import Verbs +import WordType +import WordTypeEntry +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import be.scri.helpers.DatabaseFileManager +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class ConjugateDataManagerTest { + private lateinit var fileManager: DatabaseFileManager + private lateinit var db: SQLiteDatabase + private lateinit var manager: ConjugateDataManager + + private val sampleContract = + DataContract( + nouns = Nouns(Genders(listOf("gender"), emptyList(), emptyList(), emptyList(), emptyList()), emptyMap()), + verbs = + Verbs( + conjugations = + mapOf( + 1 to + TenseGroup( + sectionTitle = "Indicative", + tenses = + mapOf( + 1 to + ConjugationCategory( + tenseTitle = "Present", + tenseForms = + mapOf( + 1 to TenseForm(label = "1ps", value = "simplePresent"), + 2 to TenseForm(label = "past", value = "simplePast"), + ), + ), + ), + ), + ), + ), + translations = + Translations( + wordType = + WordType( + sectionTitle = "Word Types", + adjective = WordTypeEntry("adj", "Adjective"), + adverb = WordTypeEntry("adv", "Adverb"), + article = WordTypeEntry("art", "Article"), + conjunction = WordTypeEntry("conj", "Conjunction"), + noun = WordTypeEntry("n", "Noun"), + postposition = WordTypeEntry("post", "Postposition"), + preposition = WordTypeEntry("prep", "Preposition"), + properNoun = WordTypeEntry("prop", "Proper Noun"), + pronoun = WordTypeEntry("pron", "Pronoun"), + verb = WordTypeEntry("v", "Verb"), + ), + ), + ) + + @BeforeEach + fun setup() { + fileManager = mockk(relaxed = true) + db = mockk(relaxed = true) + manager = ConjugateDataManager(fileManager) + } + + private fun createEmptyCursor(): Cursor = + mockk(relaxed = true) { + every { moveToFirst() } returns false + } + + private fun createRowCursor(columnValues: Map): Cursor { + val colNames = columnValues.keys.toTypedArray() + return mockk(relaxed = true) { + every { moveToFirst() } returns true + every { columnNames } returns colNames + for (i in colNames.indices) { + val colName = colNames[i] + every { getString(i) } returns columnValues[colName] + every { getColumnIndex(colName) } returns i + } + } + } + + private fun createPragmaCursor(columns: List): Cursor { + val names = columns.toList() + var index = -1 + return mockk(relaxed = true) { + every { moveToFirst() } answers { + index = 0 + names.isNotEmpty() + } + every { getColumnIndex("name") } returns 0 + every { getString(0) } answers { + if (index in names.indices) names[index] else "" + } + every { moveToNext() } answers { + index++ + index < names.size + } + } + } + + private fun setupTablePragmas(columns: List) { + every { db.rawQuery(match { it.contains("PRAGMA table_info") }, null) } answers { + createPragmaCursor(columns) + } + } + + @Test + fun `returns null when conjugate database cannot be opened`() { + every { fileManager.getConjugateDatabase("EN") } returns null + + val result = manager.getTheConjugateLabels("EN", sampleContract, "run") + assertNull(result) + } + + @Test + fun `returns null when verbs table does not exist`() { + every { fileManager.getConjugateDatabase("EN") } returns db + val tableCursor = createEmptyCursor() + every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor + + val result = manager.getTheConjugateLabels("EN", sampleContract, "run") + assertNull(result) + } + + @Test + fun `finds verb by exact infinitive column and resolves tense forms`() { + every { fileManager.getConjugateDatabase("EN") } returns db + val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } + every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor + setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) + + val rowCursor = + createRowCursor( + mapOf( + "infinitive" to "run", + "simplePresent" to "runs", + "simplePast" to "ran", + ), + ) + every { db.rawQuery(match { it.contains("FROM verbs WHERE LOWER") }, any()) } returns rowCursor + + val result = manager.getTheConjugateLabels("EN", sampleContract, "run") + assertNotNull(result) + assertEquals("runs", result?.get("Indicative")?.get("Present")?.firstOrNull()) + } + + @Test + fun `finds verb case-insensitively when user enters uppercase input`() { + every { fileManager.getConjugateDatabase("DE") } returns db + val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } + every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor + setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) + + val rowCursor = + createRowCursor( + mapOf( + "infinitive" to "haben", + "simplePresent" to "hat", + "simplePast" to "hatte", + ), + ) + every { db.rawQuery(match { it.contains("FROM verbs WHERE LOWER") }, any()) } returns rowCursor + + val result = manager.getTheConjugateLabels("DE", sampleContract, "HABEN") + assertNotNull(result) + assertEquals("hat", result?.get("Indicative")?.get("Present")?.firstOrNull()) + } + + @Test + fun `finds verb when user inputs an infinitive with 'to ' prefix`() { + every { fileManager.getConjugateDatabase("EN") } returns db + val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } + every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor + setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) + + val rowCursor = + createRowCursor( + mapOf( + "infinitive" to "walk", + "simplePresent" to "walks", + "simplePast" to "walked", + ), + ) + // First exact query for "to walk" returns empty, second prefix-stripped query for "walk" succeeds. + every { db.rawQuery(match { it.contains("FROM verbs WHERE LOWER") }, any()) } returnsMany + listOf( + createEmptyCursor(), + rowCursor, + ) + + val result = manager.getTheConjugateLabels("EN", sampleContract, "to walk") + assertNotNull(result) + assertEquals("walks", result?.get("Indicative")?.get("Present")?.firstOrNull()) + } + + @Test + fun `finds verb by inflected form fallback when user inputs a conjugated verb`() { + every { fileManager.getConjugateDatabase("EN") } returns db + val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } + every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor + setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) + + val rowCursor = + createRowCursor( + mapOf( + "infinitive" to "go", + "simplePresent" to "goes", + "simplePast" to "went", + ), + ) + + // Infinitive lookup returns empty, fallback inflected search returns row. + every { db.rawQuery(match { it.contains("SELECT * FROM verbs WHERE LOWER(\"infinitive\")") }, any()) } returns createEmptyCursor() + every { db.rawQuery(match { it.contains("OR LOWER") }, any()) } returns rowCursor + + val result = manager.getTheConjugateLabels("EN", sampleContract, "went") + assertNotNull(result) + assertEquals("goes", result?.get("Indicative")?.get("Present")?.firstOrNull()) + } + + @Test + fun `getStructuredConjugations returns labels paired with conjugated forms`() { + every { fileManager.getConjugateDatabase("EN") } returns db + val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } + every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor + setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) + + val rowCursor = + createRowCursor( + mapOf( + "infinitive" to "speak", + "simplePresent" to "speaks", + "simplePast" to "spoke", + ), + ) + every { db.rawQuery(match { it.contains("FROM verbs WHERE LOWER") }, any()) } returns rowCursor + + val structured = manager.getStructuredConjugations("EN", sampleContract, "speak") + assertNotNull(structured) + val pairs = structured?.get("Indicative")?.get("Present") + assertEquals(2, pairs?.size) + assertEquals("1ps" to "speaks", pairs?.get(0)) + assertEquals("past" to "spoke", pairs?.get(1)) + } + + @Test + fun `extractConjugateHeadings returns all form labels and the base word`() { + val headings = manager.extractConjugateHeadings(sampleContract, "dance") + assertTrue(headings.contains("1ps")) + assertTrue(headings.contains("past")) + assertTrue(headings.contains("dance")) + } +} From dd70a7a8b3fc0dbb5f90ee663176255c93e2e8db Mon Sep 17 00:00:00 2001 From: prince-0408 <66916296+prince-0408@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:05:46 +0530 Subject: [PATCH 2/3] fix(conjugate): return empty string for missing complex form verb and test against real SQLite - Return empty string immediately in parseComplexFormFromRow if the verb column is missing or empty, avoiding displaying standalone auxiliaries like 'have'. - Remove the getLanguageDatabase fallback in DatabaseFileManager.getConjugateDatabase to prevent unintentional query scope pollution in Conjugate app search. - Replace MockK cursor stubs with Robolectric in-memory SQLite tables to verify actual SQL queries and schema behaviors. --- .../be/scri/helpers/DatabaseFileManager.kt | 4 - .../scri/helpers/data/ConjugateDataManager.kt | 3 + .../helpers/data/ConjugateDataManagerTest.kt | 355 ++++++++++-------- 3 files changed, 201 insertions(+), 161 deletions(-) diff --git a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt index a70ceeb54..b89c54b8a 100644 --- a/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt +++ b/app/src/main/java/be/scri/helpers/DatabaseFileManager.kt @@ -78,10 +78,6 @@ class DatabaseFileManager( val dbFile = context.getDatabasePath(dbName) if (!dbFile.exists()) { - val languageDb = getLanguageDatabase(normalizedLang) - if (languageDb != null) { - return languageDb - } Log.w(TAG, "Conjugate database $dbName not found. User needs to download conjugate data first") return null } diff --git a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt index c33886e00..33b73b89d 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -296,6 +296,9 @@ class ConjugateDataManager( val dbColumnName = form.replace(bracketRegex, "").trim() val verbPart = getValueWithFallback(rowData, dbColumnName) + if (verbPart.isEmpty()) { + return "" + } // Try dynamic auxiliary lookup (e.g. German style: [form auxiliary_column]) return try { diff --git a/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt b/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt index ac7530d04..cd0ebde7f 100644 --- a/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt +++ b/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt @@ -11,21 +11,24 @@ import Translations import Verbs import WordType import WordTypeEntry -import android.database.Cursor import android.database.sqlite.SQLiteDatabase import be.scri.helpers.DatabaseFileManager import io.mockk.every import io.mockk.mockk -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertNull -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) class ConjugateDataManagerTest { private lateinit var fileManager: DatabaseFileManager - private lateinit var db: SQLiteDatabase private lateinit var manager: ConjugateDataManager private val sampleContract = @@ -72,200 +75,238 @@ class ConjugateDataManagerTest { ), ) - @BeforeEach + private fun createRealDatabase(): SQLiteDatabase { + val db = SQLiteDatabase.create(null) + db.execSQL( + """ + CREATE TABLE verbs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + infinitive TEXT, + simplePresent TEXT, + simplePast TEXT, + pastParticiple TEXT, + wdLexemeId TEXT, + auxiliaryVerb TEXT + ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO verbs (infinitive, simplePresent, simplePast, pastParticiple, wdLexemeId, auxiliaryVerb) + VALUES ('walk', 'walks', 'walked', 'walked', 'L100', 'have') + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO verbs (infinitive, simplePresent, simplePast, pastParticiple, wdLexemeId, auxiliaryVerb) + VALUES ('haben', 'habe', 'hatte', 'gehabt', 'L200', '') + """.trimIndent(), + ) + return db + } + + @Before fun setup() { fileManager = mockk(relaxed = true) - db = mockk(relaxed = true) manager = ConjugateDataManager(fileManager) } - private fun createEmptyCursor(): Cursor = - mockk(relaxed = true) { - every { moveToFirst() } returns false - } - - private fun createRowCursor(columnValues: Map): Cursor { - val colNames = columnValues.keys.toTypedArray() - return mockk(relaxed = true) { - every { moveToFirst() } returns true - every { columnNames } returns colNames - for (i in colNames.indices) { - val colName = colNames[i] - every { getString(i) } returns columnValues[colName] - every { getColumnIndex(colName) } returns i - } - } - } + @Test + fun getTheConjugateLabelsReturnsCorrectStructureWithRealDatabase() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } - private fun createPragmaCursor(columns: List): Cursor { - val names = columns.toList() - var index = -1 - return mockk(relaxed = true) { - every { moveToFirst() } answers { - index = 0 - names.isNotEmpty() - } - every { getColumnIndex("name") } returns 0 - every { getString(0) } answers { - if (index in names.indices) names[index] else "" - } - every { moveToNext() } answers { - index++ - index < names.size - } - } - } + val result = manager.getTheConjugateLabels("EN", sampleContract, "walk") - private fun setupTablePragmas(columns: List) { - every { db.rawQuery(match { it.contains("PRAGMA table_info") }, null) } answers { - createPragmaCursor(columns) - } + assertNotNull(result) + val indicative = result!!["Indicative"] + assertNotNull(indicative) + val present = indicative!!["Present"] + assertNotNull(present) + assertEquals(listOf("walks", "walked"), present!!.toList()) } @Test - fun `returns null when conjugate database cannot be opened`() { - every { fileManager.getConjugateDatabase("EN") } returns null + fun getStructuredConjugationsReturnsLabelsAndFormsWithRealDatabase() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } - val result = manager.getTheConjugateLabels("EN", sampleContract, "run") - assertNull(result) + val result = manager.getStructuredConjugations("EN", sampleContract, "walk") + + assertNotNull(result) + val indicative = result!!["Indicative"] + assertNotNull(indicative) + val present = indicative!!["Present"] + assertNotNull(present) + assertEquals(listOf("1ps" to "walks", "past" to "walked"), present) } @Test - fun `returns null when verbs table does not exist`() { - every { fileManager.getConjugateDatabase("EN") } returns db - val tableCursor = createEmptyCursor() - every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor + fun caseInsensitiveLookupFindsUppercaseVerb() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } - val result = manager.getTheConjugateLabels("EN", sampleContract, "run") - assertNull(result) + val result = manager.getStructuredConjugations("EN", sampleContract, "WALK") + + assertNotNull(result) + val present = result!!["Indicative"]!!["Present"]!! + assertEquals("walks", present.first { it.first == "1ps" }.second) } @Test - fun `finds verb by exact infinitive column and resolves tense forms`() { - every { fileManager.getConjugateDatabase("EN") } returns db - val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } - every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor - setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) - - val rowCursor = - createRowCursor( - mapOf( - "infinitive" to "run", - "simplePresent" to "runs", - "simplePast" to "ran", - ), - ) - every { db.rawQuery(match { it.contains("FROM verbs WHERE LOWER") }, any()) } returns rowCursor + fun prefixStrippingLookupFindsPrefixedVerb() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } + + val result = manager.getStructuredConjugations("EN", sampleContract, "to walk") - val result = manager.getTheConjugateLabels("EN", sampleContract, "run") assertNotNull(result) - assertEquals("runs", result?.get("Indicative")?.get("Present")?.firstOrNull()) + val present = result!!["Indicative"]!!["Present"]!! + assertEquals("walks", present.first { it.first == "1ps" }.second) } @Test - fun `finds verb case-insensitively when user enters uppercase input`() { - every { fileManager.getConjugateDatabase("DE") } returns db - val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } - every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor - setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) - - val rowCursor = - createRowCursor( - mapOf( - "infinitive" to "haben", - "simplePresent" to "hat", - "simplePast" to "hatte", - ), - ) - every { db.rawQuery(match { it.contains("FROM verbs WHERE LOWER") }, any()) } returns rowCursor + fun inflectedFormLookupFindsBaseVerbRow() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } + + // User typed "walked" (inflected past form), fallback search should locate the verb row + val result = manager.getStructuredConjugations("EN", sampleContract, "walked") - val result = manager.getTheConjugateLabels("DE", sampleContract, "HABEN") assertNotNull(result) - assertEquals("hat", result?.get("Indicative")?.get("Present")?.firstOrNull()) + val present = result!!["Indicative"]!!["Present"]!! + assertEquals("walks", present.first { it.first == "1ps" }.second) } @Test - fun `finds verb when user inputs an infinitive with 'to ' prefix`() { - every { fileManager.getConjugateDatabase("EN") } returns db - val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } - every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor - setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) - - val rowCursor = - createRowCursor( - mapOf( - "infinitive" to "walk", - "simplePresent" to "walks", - "simplePast" to "walked", - ), + fun parseComplexFormWithDynamicAuxiliaryResolvesCorrectly() { + val contractWithComplex = + DataContract( + nouns = sampleContract.nouns, + verbs = + Verbs( + conjugations = + mapOf( + 1 to + TenseGroup( + sectionTitle = "Perfect", + tenses = + mapOf( + 1 to + ConjugationCategory( + tenseTitle = "Present Perfect", + tenseForms = + mapOf( + 1 to + TenseForm( + label = "1ps", + value = "[simplePresent auxiliaryVerb] pastParticiple", + ), + ), + ), + ), + ), + ), + ), + translations = sampleContract.translations, ) - // First exact query for "to walk" returns empty, second prefix-stripped query for "walk" succeeds. - every { db.rawQuery(match { it.contains("FROM verbs WHERE LOWER") }, any()) } returnsMany - listOf( - createEmptyCursor(), - rowCursor, + + // Setup a database where auxiliary verb references wdLexemeId L200 ('haben') + val db = SQLiteDatabase.create(null) + db.execSQL( + """ + CREATE TABLE verbs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + infinitive TEXT, + simplePresent TEXT, + pastParticiple TEXT, + wdLexemeId TEXT, + auxiliaryVerb TEXT ) + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO verbs (infinitive, simplePresent, pastParticiple, wdLexemeId, auxiliaryVerb) + VALUES ('gehen', 'gehe', 'gegangen', 'L300', 'L200') + """.trimIndent(), + ) + db.execSQL( + """ + INSERT INTO verbs (infinitive, simplePresent, pastParticiple, wdLexemeId, auxiliaryVerb) + VALUES ('haben', 'habe', 'gehabt', 'L200', '') + """.trimIndent(), + ) + + every { fileManager.getConjugateDatabase(any()) } answers { db } + + val result = manager.getStructuredConjugations("DE", contractWithComplex, "gehen") - val result = manager.getTheConjugateLabels("EN", sampleContract, "to walk") assertNotNull(result) - assertEquals("walks", result?.get("Indicative")?.get("Present")?.firstOrNull()) + val form = result!!["Perfect"]!!["Present Perfect"]!!.first().second + assertEquals("habe gegangen", form) } @Test - fun `finds verb by inflected form fallback when user inputs a conjugated verb`() { - every { fileManager.getConjugateDatabase("EN") } returns db - val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } - every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor - setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) - - val rowCursor = - createRowCursor( - mapOf( - "infinitive" to "go", - "simplePresent" to "goes", - "simplePast" to "went", - ), + fun missingVerbColumnInComplexFormReturnsEmptyStringNotJustAuxiliary() { + val contractWithComplex = + DataContract( + nouns = sampleContract.nouns, + verbs = + Verbs( + conjugations = + mapOf( + 1 to + TenseGroup( + sectionTitle = "Perfect", + tenses = + mapOf( + 1 to + ConjugationCategory( + tenseTitle = "Future", + tenseForms = + mapOf( + 1 to + TenseForm( + label = "1ps", + value = "[will] nonexistentColumn", + ), + ), + ), + ), + ), + ), + ), + translations = sampleContract.translations, ) - // Infinitive lookup returns empty, fallback inflected search returns row. - every { db.rawQuery(match { it.contains("SELECT * FROM verbs WHERE LOWER(\"infinitive\")") }, any()) } returns createEmptyCursor() - every { db.rawQuery(match { it.contains("OR LOWER") }, any()) } returns rowCursor + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } - val result = manager.getTheConjugateLabels("EN", sampleContract, "went") - assertNotNull(result) - assertEquals("goes", result?.get("Indicative")?.get("Present")?.firstOrNull()) + val singleForm = manager.getTheValueForTheConjugateWord("walk", "[will] nonexistentColumn", "EN") + assertEquals("", singleForm) + + val result = manager.getStructuredConjugations("EN", contractWithComplex, "walk") + // Since the only form in the category produced "", the category/group is omitted + assertNull(result) } @Test - fun `getStructuredConjugations returns labels paired with conjugated forms`() { - every { fileManager.getConjugateDatabase("EN") } returns db - val tableCursor = mockk(relaxed = true) { every { moveToFirst() } returns true } - every { db.rawQuery(match { it.contains("sqlite_master") }, any()) } returns tableCursor - setupTablePragmas(listOf("infinitive", "simplePresent", "simplePast")) - - val rowCursor = - createRowCursor( - mapOf( - "infinitive" to "speak", - "simplePresent" to "speaks", - "simplePast" to "spoke", - ), - ) - every { db.rawQuery(match { it.contains("FROM verbs WHERE LOWER") }, any()) } returns rowCursor - - val structured = manager.getStructuredConjugations("EN", sampleContract, "speak") - assertNotNull(structured) - val pairs = structured?.get("Indicative")?.get("Present") - assertEquals(2, pairs?.size) - assertEquals("1ps" to "speaks", pairs?.get(0)) - assertEquals("past" to "spoke", pairs?.get(1)) + fun missingVerbReturnsNull() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } + + val result = manager.getStructuredConjugations("EN", sampleContract, "nonexistent") + assertNull(result) + } + + @Test + fun missingVerbsTableReturnsNull() { + val emptyDb = SQLiteDatabase.create(null) + every { fileManager.getConjugateDatabase(any()) } answers { emptyDb } + + val result = manager.getStructuredConjugations("EN", sampleContract, "walk") + assertNull(result) } @Test - fun `extractConjugateHeadings returns all form labels and the base word`() { - val headings = manager.extractConjugateHeadings(sampleContract, "dance") + fun extractConjugateHeadingsCollectsAllFormLabelsAndWord() { + val headings = manager.extractConjugateHeadings(sampleContract, "run") assertTrue(headings.contains("1ps")) assertTrue(headings.contains("past")) - assertTrue(headings.contains("dance")) + assertTrue(headings.contains("run")) } } From 4eb7b694d597c49bc5dc9dd5dd77021602cc6511 Mon Sep 17 00:00:00 2001 From: prince-0408 <66916296+prince-0408@users.noreply.github.com> Date: Fri, 30 Oct 2026 04:13:17 +0530 Subject: [PATCH 3/3] fix(conjugate): fallback to language database in ConjugateDataManager for keyboard users - Fall back to LanguageData.sqlite in ConjugateDataManager if ConjugateData.sqlite is not present or lacks a verbs table, resolving 'Not in Wikidata' for keyboard users (#598). - Keep DatabaseFileManager.getConjugateDatabase strictly scoped to ConjugateData so Conjugate app search is unaffected. - Default to emptyMap() in ConjugationSelectionScreen to avoid perpetual loading indicator when data is not present. - Add Robolectric tests verifying language database fallback. --- .../scri/helpers/data/ConjugateDataManager.kt | 28 +++++++++++++-- .../ui/screens/ConjugationSelectionScreen.kt | 14 ++++---- .../helpers/data/ConjugateDataManagerTest.kt | 34 +++++++++++++++++-- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt index 33b73b89d..c43ab538e 100644 --- a/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt +++ b/app/src/main/java/be/scri/helpers/data/ConjugateDataManager.kt @@ -13,6 +13,7 @@ import be.scri.helpers.DatabaseFileManager * Manages verb conjugation data by interfacing with SQLite databases. * @param fileManager The central manager for database file access. */ +@Suppress("TooManyFunctions") class ConjugateDataManager( private val fileManager: DatabaseFileManager, ) { @@ -33,7 +34,7 @@ class ConjugateDataManager( yamlData: DataContract?, word: String, ): MutableMap>>? { - val db = fileManager.getConjugateDatabase(language) ?: return null + val db = getConjugationDatabase(language) ?: return null return db.use { database -> if (!database.tableExists("verbs")) { return@use null @@ -75,7 +76,7 @@ class ConjugateDataManager( yamlData: DataContract?, word: String, ): Map>>>? { - val db = fileManager.getConjugateDatabase(language) ?: return null + val db = getConjugationDatabase(language) ?: return null return db.use { database -> if (!database.tableExists("verbs")) { return@use null @@ -145,7 +146,7 @@ class ConjugateDataManager( language: String, ): String { if (form.isNullOrEmpty()) return "" - return fileManager.getConjugateDatabase(language)?.use { db -> + return getConjugationDatabase(language)?.use { db -> if (!db.tableExists("verbs")) { return "" } @@ -155,6 +156,27 @@ class ConjugateDataManager( } ?: "" } + /** + * Retrieves the SQLite database for verb conjugation. + * Prefers the dedicated ConjugateData database, and falls back to LanguageData database + * so keyboard users whose verb data is stored in LanguageData.sqlite can conjugate verbs. + */ + private fun getConjugationDatabase(language: String): SQLiteDatabase? { + val conjugateDb = fileManager.getConjugateDatabase(language) + if (conjugateDb != null && conjugateDb.tableExists("verbs")) { + return conjugateDb + } + conjugateDb?.close() + + val languageDb = fileManager.getLanguageDatabase(language) + if (languageDb != null && languageDb.tableExists("verbs")) { + return languageDb + } + languageDb?.close() + + return null + } + /** * Retrieves the verb row from the database using case-insensitive matching, * prefix-stripping fallback, and inflected form fallback. diff --git a/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt b/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt index 42643dde3..946e0f65a 100644 --- a/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt +++ b/app/src/main/java/be/scri/ui/screens/ConjugationSelectionScreen.kt @@ -85,12 +85,14 @@ fun ConjugationSelectionScreen( LaunchedEffect(verb, languageAlias) { withContext(Dispatchers.IO) { val contract = ContractDataLoader(context).loadOrDownloadContract(languageAlias) - if (contract != null) { - val fileManager = DatabaseFileManager(context) - val manager = ConjugateDataManager(fileManager) - - conjugationData = manager.getStructuredConjugations(languageAlias, contract, verb) - } + conjugationData = + if (contract != null) { + val fileManager = DatabaseFileManager(context) + val manager = ConjugateDataManager(fileManager) + manager.getStructuredConjugations(languageAlias, contract, verb) ?: emptyMap() + } else { + emptyMap() + } } } diff --git a/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt b/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt index cd0ebde7f..ab4de82f8 100644 --- a/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt +++ b/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt @@ -294,9 +294,37 @@ class ConjugateDataManagerTest { } @Test - fun missingVerbsTableReturnsNull() { - val emptyDb = SQLiteDatabase.create(null) - every { fileManager.getConjugateDatabase(any()) } answers { emptyDb } + fun fallbackToLanguageDatabaseWhenConjugateDatabaseIsNull() { + every { fileManager.getConjugateDatabase(any()) } returns null + every { fileManager.getLanguageDatabase(any()) } answers { createRealDatabase() } + + val result = manager.getStructuredConjugations("EN", sampleContract, "walk") + assertNotNull(result) + val present = result!!["Indicative"]!!["Present"]!! + assertEquals(listOf("1ps" to "walks", "past" to "walked"), present) + + val labels = manager.getTheConjugateLabels("EN", sampleContract, "walk") + assertNotNull(labels) + assertEquals(listOf("walks", "walked"), labels!!["Indicative"]!!["Present"]!!.toList()) + } + + @Test + fun fallbackToLanguageDatabaseWhenConjugateDatabaseHasNoVerbsTable() { + every { fileManager.getConjugateDatabase(any()) } answers { SQLiteDatabase.create(null) } + every { fileManager.getLanguageDatabase(any()) } answers { createRealDatabase() } + + val result = manager.getStructuredConjugations("EN", sampleContract, "walk") + assertNotNull(result) + val present = result!!["Indicative"]!!["Present"]!! + assertEquals("walks", present.first { it.first == "1ps" }.second) + } + + @Test + fun missingVerbsTableInBothDatabasesReturnsNull() { + val emptyDb1 = SQLiteDatabase.create(null) + val emptyDb2 = SQLiteDatabase.create(null) + every { fileManager.getConjugateDatabase(any()) } answers { emptyDb1 } + every { fileManager.getLanguageDatabase(any()) } answers { emptyDb2 } val result = manager.getStructuredConjugations("EN", sampleContract, "walk") assertNull(result)