diff --git a/CHANGELOG.md b/CHANGELOG.md index 85ba73ab..1de413cf 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 c9f98e8a..b89c54b8 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,7 +73,8 @@ 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()) { 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 114a7268..c43ab538 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,22 +34,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 = getConjugationDatabase(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 = getConjugationDatabase(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 } } @@ -92,80 +146,170 @@ 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 "" } - 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 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 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 languageDb = fileManager.getLanguageDatabase(language) + if (languageDb != null && languageDb.tableExists("verbs")) { + return languageDb } + languageDb?.close() - throw IllegalArgumentException("column '$columnName' does not exist. Available columns: " + cursor.columnNames.joinToString(", ")) + return null } /** - * 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. + * Retrieves the verb row from the database using case-insensitive matching, + * prefix-stripping fallback, and inflected form fallback. */ - 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 getVerbRowData( + db: SQLiteDatabase, + word: String, + ): Map? { + val cleanWord = word.trim() + if (cleanWord.isEmpty()) return null + + 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 } } + // 3. Fallback: search if user entered an inflected form in any verb column + return findRowByInflectedForm(db, cleanWord) + } + + /** + * Searches across all columns of the `verbs` table to find the matching verb row for an inflected form. + */ + 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 +317,73 @@ class ConjugateDataManager( val auxiliaryWords = match.groupValues[1] val dbColumnName = form.replace(bracketRegex, "").trim() - return try { - val verbPart = cursor.getString(getColumnIndexWithFallback(cursor, 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() + val verbPart = getValueWithFallback(rowData, dbColumnName) + if (verbPart.isEmpty()) { + return "" + } - // 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 587c97ed..1798a8bd 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 edc46c19..946e0f65 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 @@ -84,35 +85,19 @@ 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) - - 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 = + if (contract != null) { + val fileManager = DatabaseFileManager(context) + val manager = ConjugateDataManager(fileManager) + manager.getStructuredConjugations(languageAlias, contract, verb) ?: emptyMap() + } else { + emptyMap() } - conjugationData = structuredData - } } } 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 00000000..ab4de82f --- /dev/null +++ b/app/src/test/kotlin/be/scri/helpers/data/ConjugateDataManagerTest.kt @@ -0,0 +1,340 @@ +// 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.sqlite.SQLiteDatabase +import be.scri.helpers.DatabaseFileManager +import io.mockk.every +import io.mockk.mockk +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 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"), + ), + ), + ) + + 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) + manager = ConjugateDataManager(fileManager) + } + + @Test + fun getTheConjugateLabelsReturnsCorrectStructureWithRealDatabase() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } + + val result = manager.getTheConjugateLabels("EN", sampleContract, "walk") + + assertNotNull(result) + val indicative = result!!["Indicative"] + assertNotNull(indicative) + val present = indicative!!["Present"] + assertNotNull(present) + assertEquals(listOf("walks", "walked"), present!!.toList()) + } + + @Test + fun getStructuredConjugationsReturnsLabelsAndFormsWithRealDatabase() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } + + 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 caseInsensitiveLookupFindsUppercaseVerb() { + every { fileManager.getConjugateDatabase(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 prefixStrippingLookupFindsPrefixedVerb() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } + + val result = manager.getStructuredConjugations("EN", sampleContract, "to walk") + + assertNotNull(result) + val present = result!!["Indicative"]!!["Present"]!! + assertEquals("walks", present.first { it.first == "1ps" }.second) + } + + @Test + 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") + + assertNotNull(result) + val present = result!!["Indicative"]!!["Present"]!! + assertEquals("walks", present.first { it.first == "1ps" }.second) + } + + @Test + 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, + ) + + // 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") + + assertNotNull(result) + val form = result!!["Perfect"]!!["Present Perfect"]!!.first().second + assertEquals("habe gegangen", form) + } + + @Test + 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, + ) + + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } + + 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 missingVerbReturnsNull() { + every { fileManager.getConjugateDatabase(any()) } answers { createRealDatabase() } + + val result = manager.getStructuredConjugations("EN", sampleContract, "nonexistent") + assertNull(result) + } + + @Test + 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) + } + + @Test + fun extractConjugateHeadingsCollectsAllFormLabelsAndWord() { + val headings = manager.extractConjugateHeadings(sampleContract, "run") + assertTrue(headings.contains("1ps")) + assertTrue(headings.contains("past")) + assertTrue(headings.contains("run")) + } +}