From f8a59b758bc90bbc913325fb1a9464ec2ceab686 Mon Sep 17 00:00:00 2001 From: Menny Even Danan Date: Sat, 17 Oct 2026 02:12:54 +0000 Subject: [PATCH] [LLM] Implement seed database access layer and repositories Adds pure-JVM SQLite repository interfaces and implementations for seed.db tables (dictionary, false_cognates, curated_lessons, and cognate_rules) in //core/data, along with comprehensive unit tests for single and batch queries, ordering, and prefix lookups. --- core/data/BUILD.bazel | 81 ++++++++ .../calque/data/models/CognateRule.kt | 45 +++++ .../calque/data/models/CuratedLesson.kt | 82 ++++++++ .../data/seed/CognateRulesRepository.kt | 96 +++++++++ .../data/seed/CuratedLessonsRepository.kt | 100 ++++++++++ .../calque/data/seed/DictionaryRepository.kt | 182 ++++++++++++++++++ .../data/seed/FalseCognatesRepository.kt | 156 +++++++++++++++ .../calque/data/seed/SeedDatabase.kt | 96 +++++++++ .../seed/SeedCognateRulesRepositoryTest.kt | 115 +++++++++++ .../seed/SeedCuratedLessonsRepositoryTest.kt | 131 +++++++++++++ .../calque/data/seed/SeedDatabaseTest.kt | 40 ++++ .../data/seed/SeedDictionaryRepositoryTest.kt | 157 +++++++++++++++ .../seed/SeedFalseCognatesRepositoryTest.kt | 121 ++++++++++++ 13 files changed, 1402 insertions(+) create mode 100644 core/data/BUILD.bazel create mode 100644 core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/CognateRule.kt create mode 100644 core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/CuratedLesson.kt create mode 100644 core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/CognateRulesRepository.kt create mode 100644 core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/CuratedLessonsRepository.kt create mode 100644 core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/DictionaryRepository.kt create mode 100644 core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/FalseCognatesRepository.kt create mode 100644 core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabase.kt create mode 100644 core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCognateRulesRepositoryTest.kt create mode 100644 core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCuratedLessonsRepositoryTest.kt create mode 100644 core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabaseTest.kt create mode 100644 core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDictionaryRepositoryTest.kt create mode 100644 core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedFalseCognatesRepositoryTest.kt diff --git a/core/data/BUILD.bazel b/core/data/BUILD.bazel new file mode 100644 index 0000000..0693739 --- /dev/null +++ b/core/data/BUILD.bazel @@ -0,0 +1,81 @@ +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") + +package(default_visibility = ["//visibility:public"]) + +kt_jvm_library( + name = "data", + srcs = glob(["src/main/kotlin/**/*.kt"]), + visibility = ["//visibility:public"], + deps = [ + "//core/linguistics", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + "@maven//:org_xerial_sqlite_jdbc", + ], +) + +kt_jvm_test( + name = "seed_database_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabaseTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.seed.SeedDatabaseTest", + deps = [ + ":data", + "//core/linguistics", + "@maven//:junit_junit", + "@maven//:org_xerial_sqlite_jdbc", + ], +) + +kt_jvm_test( + name = "seed_dictionary_repository_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDictionaryRepositoryTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.seed.SeedDictionaryRepositoryTest", + deps = [ + ":data", + "//core/linguistics", + "@maven//:junit_junit", + "@maven//:org_xerial_sqlite_jdbc", + ], +) + +kt_jvm_test( + name = "seed_false_cognates_repository_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedFalseCognatesRepositoryTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.seed.SeedFalseCognatesRepositoryTest", + deps = [ + ":data", + "//core/linguistics", + "@maven//:junit_junit", + "@maven//:org_xerial_sqlite_jdbc", + ], +) + +kt_jvm_test( + name = "seed_curated_lessons_repository_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCuratedLessonsRepositoryTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.seed.SeedCuratedLessonsRepositoryTest", + deps = [ + ":data", + "//core/linguistics", + "@maven//:junit_junit", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + "@maven//:org_xerial_sqlite_jdbc", + ], +) + +kt_jvm_test( + name = "seed_cognate_rules_repository_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCognateRulesRepositoryTest.kt"], + test_class = "com.anysoftkeyboard.calque.data.seed.SeedCognateRulesRepositoryTest", + deps = [ + ":data", + "//core/linguistics", + "@maven//:junit_junit", + "@maven//:org_jetbrains_kotlinx_kotlinx_serialization_json", + "@maven//:org_xerial_sqlite_jdbc", + ], +) diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/CognateRule.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/CognateRule.kt new file mode 100644 index 0000000..6f8cd3a --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/CognateRule.kt @@ -0,0 +1,45 @@ +package com.anysoftkeyboard.calque.data.models + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** Represents a single English-to-Spanish word pair in a cognate transformation rule. */ +data class CognateRuleExample( + val english: String, + val spanish: String, +) + +/** Domain model for a cognate transformation rule stored in `cognate_rules`. */ +data class CognateRule( + val ruleId: String, + val englishSuffix: String, + val spanishSuffix: String, + val genderRule: String?, + val spellingNote: String, + val examplesJson: String, +) { + /** Parses the underlying [examplesJson] string into a list of [CognateRuleExample]s. */ + fun parseExamples(): List { + if (examplesJson.isBlank()) return emptyList() + + val parsedElement = + runCatching { Json.parseToJsonElement(examplesJson) }.getOrNull() ?: return emptyList() + val examplesArray: JsonArray = + when { + parsedElement is JsonArray -> parsedElement + parsedElement is JsonObject -> + parsedElement["examples"] as? JsonArray ?: return emptyList() + else -> return emptyList() + } + + return examplesArray.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + val english = obj["english"]?.jsonPrimitive?.contentOrNull ?: "" + val spanish = obj["spanish"]?.jsonPrimitive?.contentOrNull ?: "" + CognateRuleExample(english = english, spanish = spanish) + } + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/CuratedLesson.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/CuratedLesson.kt new file mode 100644 index 0000000..fbeedec --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/models/CuratedLesson.kt @@ -0,0 +1,82 @@ +package com.anysoftkeyboard.calque.data.models + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonPrimitive + +/** Represents a single token in an interlinear word-for-word literal gloss. */ +data class InterlinearGlossToken( + val es: String, + val en: String, +) + +/** Represents a structured sentence inside a curated lesson's content. */ +data class LessonSentence( + val sentenceEs: String, + val translationEn: String, + val interlinearGloss: List = emptyList(), + val grammarCallout: String? = null, +) + +/** Domain model for a curated reading progression lesson stored in `curated_lessons`. */ +data class CuratedLesson( + val lessonId: String, + val level: Int, + val sequenceOrder: Int, + val title: String, + val targetConcept: String, + val contentJson: String, +) { + /** Parses the underlying [contentJson] string into a list of [LessonSentence]s. */ + fun parseSentences(): List { + if (contentJson.isBlank()) return emptyList() + + val parsedElement = + runCatching { Json.parseToJsonElement(contentJson) }.getOrNull() ?: return emptyList() + val sentencesArray: JsonArray = + when { + parsedElement is JsonArray -> parsedElement + parsedElement is JsonObject -> + parsedElement["sentences"] as? JsonArray ?: return emptyList() + else -> return emptyList() + } + + return sentencesArray.mapNotNull { element -> + val obj = element as? JsonObject ?: return@mapNotNull null + parseSentenceObject(obj) + } + } + + private fun parseSentenceObject(obj: JsonObject): LessonSentence { + val sentenceEs = + obj["sentence_es"]?.jsonPrimitive?.contentOrNull + ?: obj["sentenceEs"]?.jsonPrimitive?.contentOrNull + ?: "" + val translationEn = + obj["translation_en"]?.jsonPrimitive?.contentOrNull + ?: obj["translationEn"]?.jsonPrimitive?.contentOrNull + ?: "" + val grammarCallout = + obj["grammar_callout"]?.jsonPrimitive?.contentOrNull + ?: obj["grammarCallout"]?.jsonPrimitive?.contentOrNull + + val rawGlossArray = (obj["interlinear_gloss"] ?: obj["interlinearGloss"]) as? JsonArray + + val interlinearGloss = + rawGlossArray?.mapNotNull { glossElem -> + val glossObj = glossElem as? JsonObject ?: return@mapNotNull null + val es = glossObj["es"]?.jsonPrimitive?.contentOrNull.orEmpty() + val en = glossObj["en"]?.jsonPrimitive?.contentOrNull.orEmpty() + InterlinearGlossToken(es = es, en = en) + } ?: emptyList() + + return LessonSentence( + sentenceEs = sentenceEs, + translationEn = translationEn, + interlinearGloss = interlinearGloss, + grammarCallout = grammarCallout, + ) + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/CognateRulesRepository.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/CognateRulesRepository.kt new file mode 100644 index 0000000..c253f43 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/CognateRulesRepository.kt @@ -0,0 +1,96 @@ +package com.anysoftkeyboard.calque.data.seed + +import com.anysoftkeyboard.calque.data.models.CognateRule +import java.sql.Connection +import java.sql.ResultSet +import java.util.Locale + +/** Repository interface for accessing cognate transformation rules. */ +interface CognateRulesRepository { + /** Retrieves all cognate rules. */ + fun getAll(): List + + /** Retrieves a cognate rule by its unique identifier (e.g. "tion_to_cion"). */ + fun getById(ruleId: String): CognateRule? + + /** + * Finds the most specific cognate rule applicable to a Spanish word, matching the longest + * [CognateRule.spanishSuffix]. + */ + fun findRuleForSpanishWord(spanishWord: String): CognateRule? +} + +/** SQLite-backed implementation of [CognateRulesRepository]. */ +class SqliteCognateRulesRepository( + private val connection: Connection, +) : CognateRulesRepository { + + override fun getAll(): List { + val sql = + """ + SELECT rule_id, english_suffix, spanish_suffix, gender_rule, spelling_note, examples_json + FROM cognate_rules + ORDER BY rowid ASC + """ + .trimIndent() + + val results = mutableListOf() + connection.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + while (rs.next()) { + results.add(mapRow(rs)) + } + } + } + return results + } + + override fun getById(ruleId: String): CognateRule? { + val trimmed = ruleId.trim() + if (trimmed.isEmpty()) return null + + val sql = + """ + SELECT rule_id, english_suffix, spanish_suffix, gender_rule, spelling_note, examples_json + FROM cognate_rules + WHERE rule_id = ? + LIMIT 1 + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(1, trimmed) + stmt.executeQuery().use { rs -> + return if (rs.next()) mapRow(rs) else null + } + } + } + + override fun findRuleForSpanishWord(spanishWord: String): CognateRule? { + val normalized = spanishWord.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return null + + val rules = getAll() + return rules + .filter { rule -> + val cleanSuffix = cleanSuffix(rule.spanishSuffix) + cleanSuffix.isNotEmpty() && normalized.endsWith(cleanSuffix) + } + .maxByOrNull { rule -> cleanSuffix(rule.spanishSuffix).length } + } + + private fun cleanSuffix(suffix: String): String { + return suffix.lowercase(Locale.ROOT).trim().removePrefix("-") + } + + private fun mapRow(rs: ResultSet): CognateRule { + return CognateRule( + ruleId = rs.getString("rule_id"), + englishSuffix = rs.getString("english_suffix"), + spanishSuffix = rs.getString("spanish_suffix"), + genderRule = rs.getString("gender_rule"), + spellingNote = rs.getString("spelling_note"), + examplesJson = rs.getString("examples_json"), + ) + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/CuratedLessonsRepository.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/CuratedLessonsRepository.kt new file mode 100644 index 0000000..203195f --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/CuratedLessonsRepository.kt @@ -0,0 +1,100 @@ +package com.anysoftkeyboard.calque.data.seed + +import com.anysoftkeyboard.calque.data.models.CuratedLesson +import java.sql.Connection +import java.sql.ResultSet + +/** Repository interface for accessing curated syntactic progression lessons. */ +interface CuratedLessonsRepository { + /** Retrieves all curated lessons, ordered by level and sequence order. */ + fun getAll(): List + + /** + * Retrieves curated lessons for a specific difficulty level (0, 1, 2, ...), ordered by sequence + * order. + */ + fun getByLevel(level: Int): List + + /** Retrieves a curated lesson by its unique identifier (e.g. "lvl0_00_cognate_unlock"). */ + fun getById(lessonId: String): CuratedLesson? +} + +/** SQLite-backed implementation of [CuratedLessonsRepository]. */ +class SqliteCuratedLessonsRepository( + private val connection: Connection, +) : CuratedLessonsRepository { + + override fun getAll(): List { + val sql = + """ + SELECT lesson_id, level, sequence_order, title, target_concept, content_json + FROM curated_lessons + ORDER BY level ASC, sequence_order ASC + """ + .trimIndent() + + val results = mutableListOf() + connection.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + while (rs.next()) { + results.add(mapRow(rs)) + } + } + } + return results + } + + override fun getByLevel(level: Int): List { + val sql = + """ + SELECT lesson_id, level, sequence_order, title, target_concept, content_json + FROM curated_lessons + WHERE level = ? + ORDER BY sequence_order ASC + """ + .trimIndent() + + val results = mutableListOf() + connection.prepareStatement(sql).use { stmt -> + stmt.setInt(1, level) + stmt.executeQuery().use { rs -> + while (rs.next()) { + results.add(mapRow(rs)) + } + } + } + return results + } + + override fun getById(lessonId: String): CuratedLesson? { + val trimmed = lessonId.trim() + if (trimmed.isEmpty()) return null + + val sql = + """ + SELECT lesson_id, level, sequence_order, title, target_concept, content_json + FROM curated_lessons + WHERE lesson_id = ? + LIMIT 1 + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(1, trimmed) + stmt.executeQuery().use { rs -> + return if (rs.next()) mapRow(rs) else null + } + } + } + + private fun mapRow(rs: ResultSet): CuratedLesson { + return CuratedLesson( + lessonId = rs.getString("lesson_id"), + level = rs.getInt("level"), + sequenceOrder = rs.getInt("sequence_order"), + title = rs.getString("title"), + targetConcept = rs.getString("target_concept"), + contentJson = rs.getString("content_json"), + ) + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/DictionaryRepository.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/DictionaryRepository.kt new file mode 100644 index 0000000..b3681b7 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/DictionaryRepository.kt @@ -0,0 +1,182 @@ +package com.anysoftkeyboard.calque.data.seed + +import com.anysoftkeyboard.calque.linguistics.DictionaryEntry +import java.sql.Connection +import java.sql.ResultSet +import java.util.Locale + +/** Repository interface for accessing read-only seed dictionary data. */ +interface DictionaryRepository { + /** Looks up a single dictionary entry by surface form (case-insensitive). */ + fun getBySurfaceForm(surfaceForm: String): DictionaryEntry? + + /** + * Batch queries dictionary entries for a collection of surface forms. Returns a map keyed by + * normalized lowercase surface form. + */ + fun getBySurfaceForms(surfaceForms: Collection): Map + + /** Retrieves all surface forms associated with the given base lemma. */ + fun getSurfacesForLemma(lemma: String): List + + /** Retrieves all dictionary entries associated with the given base lemma. */ + fun getEntriesForLemma(lemma: String): List + + /** Searches dictionary entries starting with the given surface form prefix. */ + fun searchByPrefix(prefix: String, limit: Int = DEFAULT_SEARCH_LIMIT): List + + companion object { + const val DEFAULT_SEARCH_LIMIT = 50 + } +} + +/** SQLite-backed implementation of [DictionaryRepository]. */ +class SqliteDictionaryRepository( + private val connection: Connection, +) : DictionaryRepository { + + override fun getBySurfaceForm(surfaceForm: String): DictionaryEntry? { + val normalized = surfaceForm.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return null + + val sql = + """ + SELECT surface_form, lemma, pos, grammar_info, english_gloss, is_cognate, frequency_rank + FROM dictionary + WHERE surface_form = ? + LIMIT 1 + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(1, normalized) + stmt.executeQuery().use { rs -> + return if (rs.next()) mapRow(rs) else null + } + } + } + + override fun getBySurfaceForms(surfaceForms: Collection): Map { + val uniqueForms = + surfaceForms + .map { it.lowercase(Locale.ROOT).trim() } + .filter { it.isNotEmpty() } + .distinct() + if (uniqueForms.isEmpty()) return emptyMap() + + val results = mutableMapOf() + for (chunk in uniqueForms.chunked(MAX_BATCH_CHUNK_SIZE)) { + queryChunk(chunk, results) + } + return results + } + + private fun queryChunk(chunk: List, results: MutableMap) { + val placeholders = chunk.joinToString(",") { "?" } + val sql = + """ + SELECT surface_form, lemma, pos, grammar_info, english_gloss, is_cognate, frequency_rank + FROM dictionary + WHERE surface_form IN ($placeholders) + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + chunk.forEachIndexed { index, form -> stmt.setString(index + 1, form) } + stmt.executeQuery().use { rs -> + while (rs.next()) { + val entry = mapRow(rs) + results[entry.surfaceForm] = entry + } + } + } + } + + override fun getSurfacesForLemma(lemma: String): List { + val normalized = lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return emptyList() + + val sql = "SELECT surface_form FROM dictionary WHERE lemma = ? ORDER BY surface_form ASC" + val surfaces = mutableListOf() + connection.prepareStatement(sql).use { stmt -> + stmt.setString(1, normalized) + stmt.executeQuery().use { rs -> + while (rs.next()) { + surfaces.add(rs.getString("surface_form")) + } + } + } + return surfaces + } + + override fun getEntriesForLemma(lemma: String): List { + val normalized = lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return emptyList() + + val sql = + """ + SELECT surface_form, lemma, pos, grammar_info, english_gloss, is_cognate, frequency_rank + FROM dictionary + WHERE lemma = ? + ORDER BY surface_form ASC + """ + .trimIndent() + + val entries = mutableListOf() + connection.prepareStatement(sql).use { stmt -> + stmt.setString(1, normalized) + stmt.executeQuery().use { rs -> + while (rs.next()) { + entries.add(mapRow(rs)) + } + } + } + return entries + } + + override fun searchByPrefix(prefix: String, limit: Int): List { + val normalized = prefix.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty() || limit <= 0) return emptyList() + + val escapedPrefix = normalized.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + val sql = + """ + SELECT surface_form, lemma, pos, grammar_info, english_gloss, is_cognate, frequency_rank + FROM dictionary + WHERE surface_form LIKE ? ESCAPE '\' + ORDER BY frequency_rank IS NULL, frequency_rank ASC, surface_form ASC + LIMIT ? + """ + .trimIndent() + + val entries = mutableListOf() + connection.prepareStatement(sql).use { stmt -> + stmt.setString(1, "$escapedPrefix%") + stmt.setInt(2, limit) + stmt.executeQuery().use { rs -> + while (rs.next()) { + entries.add(mapRow(rs)) + } + } + } + return entries + } + + private fun mapRow(rs: ResultSet): DictionaryEntry { + val freqObj = rs.getObject("frequency_rank") + val freqRank = if (freqObj != null) rs.getInt("frequency_rank") else null + return DictionaryEntry( + surfaceForm = rs.getString("surface_form"), + lemma = rs.getString("lemma"), + pos = rs.getString("pos"), + grammarInfo = rs.getString("grammar_info"), + englishGloss = rs.getString("english_gloss"), + isCognate = rs.getInt("is_cognate") == 1, + frequencyRank = freqRank, + ) + } + + private companion object { + const val MAX_BATCH_CHUNK_SIZE = 400 + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/FalseCognatesRepository.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/FalseCognatesRepository.kt new file mode 100644 index 0000000..f42ea33 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/FalseCognatesRepository.kt @@ -0,0 +1,156 @@ +package com.anysoftkeyboard.calque.data.seed + +import com.anysoftkeyboard.calque.linguistics.FalseCognateEntry +import java.sql.Connection +import java.sql.ResultSet +import java.util.Locale + +/** Repository interface for accessing false cognate trap entries. */ +interface FalseCognatesRepository { + /** Looks up a false cognate entry by surface form (case-insensitive). */ + fun getBySurfaceForm(surfaceForm: String): FalseCognateEntry? + + /** + * Batch queries false cognate entries for a collection of surface forms. Returns a map keyed by + * normalized lowercase surface form. + */ + fun getBySurfaceForms(surfaceForms: Collection): Map + + /** Retrieves all false cognate forms belonging to a given lemma. */ + fun getByLemma(lemma: String): List + + /** Returns all false cognate entries stored in the database. */ + fun getAll(): List +} + +/** SQLite-backed implementation of [FalseCognatesRepository]. */ +class SqliteFalseCognatesRepository( + private val connection: Connection, +) : FalseCognatesRepository { + + override fun getBySurfaceForm(surfaceForm: String): FalseCognateEntry? { + val normalized = surfaceForm.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return null + + val sql = + """ + SELECT surface_form, lemma, apparent_english, actual_english, real_spanish_for_apparent, + severity, disambiguation_tip, example_es, example_en + FROM false_cognates + WHERE surface_form = ? + LIMIT 1 + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + stmt.setString(1, normalized) + stmt.executeQuery().use { rs -> + return if (rs.next()) mapRow(rs) else null + } + } + } + + override fun getBySurfaceForms( + surfaceForms: Collection + ): Map { + val uniqueForms = + surfaceForms + .map { it.lowercase(Locale.ROOT).trim() } + .filter { it.isNotEmpty() } + .distinct() + if (uniqueForms.isEmpty()) return emptyMap() + + val results = mutableMapOf() + for (chunk in uniqueForms.chunked(MAX_BATCH_CHUNK_SIZE)) { + queryChunk(chunk, results) + } + return results + } + + private fun queryChunk(chunk: List, results: MutableMap) { + val placeholders = chunk.joinToString(",") { "?" } + val sql = + """ + SELECT surface_form, lemma, apparent_english, actual_english, real_spanish_for_apparent, + severity, disambiguation_tip, example_es, example_en + FROM false_cognates + WHERE surface_form IN ($placeholders) + """ + .trimIndent() + + connection.prepareStatement(sql).use { stmt -> + chunk.forEachIndexed { index, form -> stmt.setString(index + 1, form) } + stmt.executeQuery().use { rs -> + while (rs.next()) { + val entry = mapRow(rs) + results[entry.surfaceForm] = entry + } + } + } + } + + override fun getByLemma(lemma: String): List { + val normalized = lemma.lowercase(Locale.ROOT).trim() + if (normalized.isEmpty()) return emptyList() + + val sql = + """ + SELECT surface_form, lemma, apparent_english, actual_english, real_spanish_for_apparent, + severity, disambiguation_tip, example_es, example_en + FROM false_cognates + WHERE lemma = ? + ORDER BY surface_form ASC + """ + .trimIndent() + + val results = mutableListOf() + connection.prepareStatement(sql).use { stmt -> + stmt.setString(1, normalized) + stmt.executeQuery().use { rs -> + while (rs.next()) { + results.add(mapRow(rs)) + } + } + } + return results + } + + override fun getAll(): List { + val sql = + """ + SELECT surface_form, lemma, apparent_english, actual_english, real_spanish_for_apparent, + severity, disambiguation_tip, example_es, example_en + FROM false_cognates + ORDER BY surface_form ASC + """ + .trimIndent() + + val results = mutableListOf() + connection.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + while (rs.next()) { + results.add(mapRow(rs)) + } + } + } + return results + } + + private fun mapRow(rs: ResultSet): FalseCognateEntry { + return FalseCognateEntry( + surfaceForm = rs.getString("surface_form"), + lemma = rs.getString("lemma"), + apparentEnglish = rs.getString("apparent_english"), + actualEnglish = rs.getString("actual_english"), + realSpanishForApparent = rs.getString("real_spanish_for_apparent"), + severity = rs.getString("severity"), + disambiguationTip = rs.getString("disambiguation_tip"), + exampleEs = rs.getString("example_es"), + exampleEn = rs.getString("example_en"), + ) + } + + private companion object { + const val MAX_BATCH_CHUNK_SIZE = 400 + } +} diff --git a/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabase.kt b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabase.kt new file mode 100644 index 0000000..a75d4e5 --- /dev/null +++ b/core/data/src/main/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabase.kt @@ -0,0 +1,96 @@ +package com.anysoftkeyboard.calque.data.seed + +import java.sql.Connection +import java.sql.DriverManager + +/** + * Encapsulates the read-only pre-packaged seed SQLite database containing [dictionary], + * [falseCognates], [curatedLessons], and [cognateRules]. + */ +class SeedDatabase( + val connection: Connection, +) : AutoCloseable { + + val dictionary: DictionaryRepository = SqliteDictionaryRepository(connection) + val falseCognates: FalseCognatesRepository = SqliteFalseCognatesRepository(connection) + val curatedLessons: CuratedLessonsRepository = SqliteCuratedLessonsRepository(connection) + val cognateRules: CognateRulesRepository = SqliteCognateRulesRepository(connection) + + override fun close() { + connection.close() + } + + companion object { + private val SEED_TABLE_STATEMENTS = + listOf( + """ + CREATE TABLE IF NOT EXISTS dictionary ( + surface_form TEXT PRIMARY KEY, + lemma TEXT NOT NULL, + pos TEXT NOT NULL, + grammar_info TEXT, + english_gloss TEXT NOT NULL, + is_cognate INTEGER NOT NULL, + frequency_rank INTEGER + ) + """ + .trimIndent(), + "CREATE INDEX IF NOT EXISTS idx_dictionary_lemma ON dictionary(lemma)", + """ + CREATE TABLE IF NOT EXISTS false_cognates ( + surface_form TEXT PRIMARY KEY, + lemma TEXT NOT NULL, + apparent_english TEXT NOT NULL, + actual_english TEXT NOT NULL, + real_spanish_for_apparent TEXT NOT NULL, + severity TEXT NOT NULL, + disambiguation_tip TEXT NOT NULL, + example_es TEXT NOT NULL, + example_en TEXT NOT NULL + ) + """ + .trimIndent(), + "CREATE INDEX IF NOT EXISTS idx_false_cognates_lemma ON false_cognates(lemma)", + """ + CREATE TABLE IF NOT EXISTS curated_lessons ( + lesson_id TEXT PRIMARY KEY, + level INTEGER NOT NULL, + sequence_order INTEGER NOT NULL, + title TEXT NOT NULL, + target_concept TEXT NOT NULL, + content_json TEXT NOT NULL + ) + """ + .trimIndent(), + "CREATE INDEX IF NOT EXISTS idx_curated_lessons_level ON curated_lessons(level)", + """ + CREATE TABLE IF NOT EXISTS cognate_rules ( + rule_id TEXT PRIMARY KEY, + english_suffix TEXT NOT NULL, + spanish_suffix TEXT NOT NULL, + gender_rule TEXT, + spelling_note TEXT NOT NULL, + examples_json TEXT NOT NULL + ) + """ + .trimIndent(), + ) + + /** Opens an SQLite database connection at the given [jdbcUrl]. */ + fun open(jdbcUrl: String = "jdbc:sqlite::memory:"): SeedDatabase { + val connection = DriverManager.getConnection(jdbcUrl) + return SeedDatabase(connection) + } + + /** + * Creates all seed tables and indexes defined in docs/DATA.md on the provided [connection]. + */ + fun createTables(connection: Connection) { + connection.createStatement().use { stmt -> + for (sql in SEED_TABLE_STATEMENTS) { + stmt.execute(sql) + } + } + } + } +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCognateRulesRepositoryTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCognateRulesRepositoryTest.kt new file mode 100644 index 0000000..dcd184d --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCognateRulesRepositoryTest.kt @@ -0,0 +1,115 @@ +package com.anysoftkeyboard.calque.data.seed + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +class SeedCognateRulesRepositoryTest { + + private lateinit var database: SeedDatabase + + @Before + fun setUp() { + database = SeedDatabase.open() + SeedDatabase.createTables(database.connection) + insertTestData() + } + + @After + fun tearDown() { + database.close() + } + + private fun insertTestData() { + val sampleExamples = + """ + [ + {"english": "nation", "spanish": "nación"}, + {"english": "action", "spanish": "acción"} + ] + """ + .trimIndent() + + val sql = + """ + INSERT INTO cognate_rules (rule_id, english_suffix, spanish_suffix, gender_rule, spelling_note, examples_json) + VALUES (?, ?, ?, ?, ?, ?) + """ + .trimIndent() + + database.connection.prepareStatement(sql).use { stmt -> + // Rule 1: tion -> ción + stmt.setString(1, "tion_to_cion") + stmt.setString(2, "-tion") + stmt.setString(3, "-ción") + stmt.setString(4, "Always feminine (la)") + stmt.setString(5, "Drop double consonants") + stmt.setString(6, sampleExamples) + stmt.addBatch() + + // Rule 2: ty -> dad + stmt.setString(1, "ty_to_dad") + stmt.setString(2, "-ty") + stmt.setString(3, "-dad") + stmt.setString(4, "Always feminine (la)") + stmt.setString(5, "Regular shift") + stmt.setString(6, "[]") + stmt.addBatch() + + // Rule 3: sion -> sión + stmt.setString(1, "sion_to_sion") + stmt.setString(2, "-sion") + stmt.setString(3, "-sión") + stmt.setString(4, "Always feminine (la)") + stmt.setString(5, "Retains single s") + stmt.setString(6, "[]") + stmt.addBatch() + + stmt.executeBatch() + } + } + + @Test + fun getAll_returnsAllRules() { + val rules = database.cognateRules.getAll() + assertEquals(3, rules.size) + assertEquals("tion_to_cion", rules[0].ruleId) + assertEquals("ty_to_dad", rules[1].ruleId) + assertEquals("sion_to_sion", rules[2].ruleId) + } + + @Test + fun getById_andParseExamples() { + val rule = database.cognateRules.getById("tion_to_cion") + assertNotNull(rule) + assertEquals("-tion", rule?.englishSuffix) + assertEquals("-ción", rule?.spanishSuffix) + assertEquals("Always feminine (la)", rule?.genderRule) + + val examples = rule?.parseExamples() + assertNotNull(examples) + assertEquals(2, examples?.size) + assertEquals("nation", examples!![0].english) + assertEquals("nación", examples[0].spanish) + + val notFound = database.cognateRules.getById("inexistent_rule") + assertNull(notFound) + } + + @Test + fun findRuleForSpanishWord_matchesLongestSuffix() { + val ruleNation = database.cognateRules.findRuleForSpanishWord("conversación") + assertNotNull(ruleNation) + assertEquals("tion_to_cion", ruleNation?.ruleId) + + val ruleCity = database.cognateRules.findRuleForSpanishWord("ciudad") + assertNotNull(ruleCity) + assertEquals("ty_to_dad", ruleCity?.ruleId) + + val noMatch = database.cognateRules.findRuleForSpanishWord("perro") + assertNull(noMatch) + } +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCuratedLessonsRepositoryTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCuratedLessonsRepositoryTest.kt new file mode 100644 index 0000000..79851c8 --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedCuratedLessonsRepositoryTest.kt @@ -0,0 +1,131 @@ +package com.anysoftkeyboard.calque.data.seed + +import org.junit.After +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 + +class SeedCuratedLessonsRepositoryTest { + + private lateinit var database: SeedDatabase + + @Before + fun setUp() { + database = SeedDatabase.open() + SeedDatabase.createTables(database.connection) + insertTestData() + } + + @After + fun tearDown() { + database.close() + } + + private fun insertTestData() { + val sampleJson = + """ + [ + { + "sentenceEs": "El hotel es excelente.", + "translationEn": "The hotel is excellent.", + "interlinearGloss": [ + {"es": "El", "en": "The"}, + {"es": "hotel", "en": "hotel"}, + {"es": "es", "en": "is"}, + {"es": "excelente", "en": "excellent"} + ], + "grammarCallout": "Zero-translation cognates" + } + ] + """ + .trimIndent() + + val sql = + """ + INSERT INTO curated_lessons (lesson_id, level, sequence_order, title, target_concept, content_json) + VALUES (?, ?, ?, ?, ?, ?) + """ + .trimIndent() + + database.connection.prepareStatement(sql).use { stmt -> + // Lesson 1: Level 0, seq 1 + stmt.setString(1, "lvl0_00_cognate_unlock") + stmt.setInt(2, 0) + stmt.setInt(3, 1) + stmt.setString(4, "Cognate Unlock") + stmt.setString(5, "Transparent cognates require zero translation effort.") + stmt.setString(6, sampleJson) + stmt.addBatch() + + // Lesson 2: Level 0, seq 2 + stmt.setString(1, "lvl0_01_svo_cognates") + stmt.setInt(2, 0) + stmt.setInt(3, 2) + stmt.setString(4, "Simple SVO Sentences") + stmt.setString(5, "Spanish declarative sentences follow Subject-Verb-Object.") + stmt.setString(6, "[]") + stmt.addBatch() + + // Lesson 3: Level 1, seq 1 + stmt.setString(1, "lvl1_01_oficina") + stmt.setInt(2, 1) + stmt.setInt(3, 1) + stmt.setString(4, "El problema en la oficina") + stmt.setString(5, "Present-tense narrative with false cognates.") + stmt.setString(6, "[]") + stmt.addBatch() + + stmt.executeBatch() + } + } + + @Test + fun getAll_returnsOrderedByLevelThenSequence() { + val lessons = database.curatedLessons.getAll() + assertEquals(3, lessons.size) + assertEquals("lvl0_00_cognate_unlock", lessons[0].lessonId) + assertEquals("lvl0_01_svo_cognates", lessons[1].lessonId) + assertEquals("lvl1_01_oficina", lessons[2].lessonId) + } + + @Test + fun getByLevel_filtersCorrectly() { + val level0 = database.curatedLessons.getByLevel(0) + assertEquals(2, level0.size) + assertEquals("lvl0_00_cognate_unlock", level0[0].lessonId) + assertEquals("lvl0_01_svo_cognates", level0[1].lessonId) + + val level1 = database.curatedLessons.getByLevel(1) + assertEquals(1, level1.size) + assertEquals("lvl1_01_oficina", level1[0].lessonId) + + val level99 = database.curatedLessons.getByLevel(99) + assertTrue(level99.isEmpty()) + } + + @Test + fun getById_andParseSentences() { + val lesson = database.curatedLessons.getById("lvl0_00_cognate_unlock") + assertNotNull(lesson) + assertEquals("Cognate Unlock", lesson?.title) + assertEquals(0, lesson?.level) + assertEquals(1, lesson?.sequenceOrder) + + val sentences = lesson?.parseSentences() + assertNotNull(sentences) + assertEquals(1, sentences?.size) + val s = sentences!![0] + assertEquals("El hotel es excelente.", s.sentenceEs) + assertEquals("The hotel is excellent.", s.translationEn) + assertEquals(4, s.interlinearGloss.size) + assertEquals("hotel", s.interlinearGloss[1].es) + assertEquals("hotel", s.interlinearGloss[1].en) + assertEquals("Zero-translation cognates", s.grammarCallout) + + val notFound = database.curatedLessons.getById("non_existent") + assertNull(notFound) + } +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabaseTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabaseTest.kt new file mode 100644 index 0000000..4842386 --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDatabaseTest.kt @@ -0,0 +1,40 @@ +package com.anysoftkeyboard.calque.data.seed + +import java.sql.Connection +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class SeedDatabaseTest { + + @Test + fun openInMemoryDatabase_createsAllSeedTablesAndIndices() { + SeedDatabase.open().use { db -> + SeedDatabase.createTables(db.connection) + + assertNotNull(db.dictionary) + assertNotNull(db.falseCognates) + assertNotNull(db.curatedLessons) + assertNotNull(db.cognateRules) + + val tables = queryExistingTableNames(db.connection) + assertTrue(tables.contains("dictionary")) + assertTrue(tables.contains("false_cognates")) + assertTrue(tables.contains("curated_lessons")) + assertTrue(tables.contains("cognate_rules")) + } + } + + private fun queryExistingTableNames(connection: Connection): Set { + val tables = mutableSetOf() + val sql = "SELECT name FROM sqlite_master WHERE type = 'table'" + connection.createStatement().use { stmt -> + stmt.executeQuery(sql).use { rs -> + while (rs.next()) { + tables.add(rs.getString("name")) + } + } + } + return tables + } +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDictionaryRepositoryTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDictionaryRepositoryTest.kt new file mode 100644 index 0000000..bb04dda --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedDictionaryRepositoryTest.kt @@ -0,0 +1,157 @@ +package com.anysoftkeyboard.calque.data.seed + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SeedDictionaryRepositoryTest { + + private lateinit var database: SeedDatabase + + @Before + fun setUp() { + database = SeedDatabase.open() + SeedDatabase.createTables(database.connection) + insertTestData() + } + + @After + fun tearDown() { + database.close() + } + + private fun insertTestData() { + val sql = + """ + INSERT INTO dictionary (surface_form, lemma, pos, grammar_info, english_gloss, is_cognate, frequency_rank) + VALUES (?, ?, ?, ?, ?, ?, ?) + """ + .trimIndent() + + database.connection.prepareStatement(sql).use { stmt -> + // Entry 1: hablar (base verb) + stmt.setString(1, "hablar") + stmt.setString(2, "hablar") + stmt.setString(3, "verb") + stmt.setString(4, "infinitive") + stmt.setString(5, "to speak") + stmt.setInt(6, 0) + stmt.setInt(7, 45) + stmt.addBatch() + + // Entry 2: hablamos (inflected) + stmt.setString(1, "hablamos") + stmt.setString(2, "hablar") + stmt.setString(3, "verb") + stmt.setString(4, "1st pl. present") + stmt.setString(5, "to speak") + stmt.setInt(6, 0) + stmt.setInt(7, 320) + stmt.addBatch() + + // Entry 3: hotel (cognate noun) + stmt.setString(1, "hotel") + stmt.setString(2, "hotel") + stmt.setString(3, "noun") + stmt.setString(4, "masculine singular") + stmt.setString(5, "hotel") + stmt.setInt(6, 1) + stmt.setInt(7, 1200) + stmt.addBatch() + + // Entry 4: raro without frequency + stmt.setString(1, "raro") + stmt.setString(2, "raro") + stmt.setString(3, "adj") + stmt.setNull(4, java.sql.Types.VARCHAR) + stmt.setString(5, "rare / strange") + stmt.setInt(6, 1) + stmt.setNull(7, java.sql.Types.INTEGER) + stmt.addBatch() + + stmt.executeBatch() + } + } + + @Test + fun getBySurfaceForm_exactAndCaseInsensitiveLookup() { + val entry = database.dictionary.getBySurfaceForm("hablar") + assertNotNull(entry) + assertEquals("hablar", entry?.surfaceForm) + assertEquals("hablar", entry?.lemma) + assertEquals("verb", entry?.pos) + assertEquals("infinitive", entry?.grammarInfo) + assertEquals("to speak", entry?.englishGloss) + assertFalse(entry!!.isCognate) + assertEquals(45, entry.frequencyRank) + + // Case-insensitivity and whitespace trim + val upperEntry = database.dictionary.getBySurfaceForm(" HABLAMOS ") + assertNotNull(upperEntry) + assertEquals("hablamos", upperEntry?.surfaceForm) + + // Unknown surface returns null + val notFound = database.dictionary.getBySurfaceForm("desconocido") + assertNull(notFound) + } + + @Test + fun getBySurfaceForms_batchLookupWithChunks() { + val results = + database.dictionary.getBySurfaceForms(setOf("hablar", "hotel", "desconocido", "raro")) + assertEquals(3, results.size) + assertTrue(results.containsKey("hablar")) + assertTrue(results.containsKey("hotel")) + assertTrue(results.containsKey("raro")) + assertFalse(results.containsKey("desconocido")) + + val hotel = results["hotel"] + assertNotNull(hotel) + assertTrue(hotel!!.isCognate) + + val raro = results["raro"] + assertNotNull(raro) + assertNull(raro?.frequencyRank) + assertNull(raro?.grammarInfo) + } + + @Test + fun getBySurfaceForms_emptyInputReturnsEmptyMap() { + val results = database.dictionary.getBySurfaceForms(emptyList()) + assertTrue(results.isEmpty()) + } + + @Test + fun getSurfacesForLemma_returnsAllInflections() { + val surfaces = database.dictionary.getSurfacesForLemma("hablar") + assertEquals(listOf("hablamos", "hablar"), surfaces) + + val empty = database.dictionary.getSurfacesForLemma("noexiste") + assertTrue(empty.isEmpty()) + } + + @Test + fun getEntriesForLemma_returnsAllEntries() { + val entries = database.dictionary.getEntriesForLemma("hablar") + assertEquals(2, entries.size) + assertEquals("hablamos", entries[0].surfaceForm) + assertEquals("hablar", entries[1].surfaceForm) + } + + @Test + fun searchByPrefix_matchesPrefixOrderedByFrequency() { + val results = database.dictionary.searchByPrefix("hab") + assertEquals(2, results.size) + // 45 comes before 320 + assertEquals("hablar", results[0].surfaceForm) + assertEquals("hablamos", results[1].surfaceForm) + + val noMatch = database.dictionary.searchByPrefix("xyz") + assertTrue(noMatch.isEmpty()) + } +} diff --git a/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedFalseCognatesRepositoryTest.kt b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedFalseCognatesRepositoryTest.kt new file mode 100644 index 0000000..f55e35a --- /dev/null +++ b/core/data/src/test/kotlin/com/anysoftkeyboard/calque/data/seed/SeedFalseCognatesRepositoryTest.kt @@ -0,0 +1,121 @@ +package com.anysoftkeyboard.calque.data.seed + +import org.junit.After +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 + +class SeedFalseCognatesRepositoryTest { + + private lateinit var database: SeedDatabase + + @Before + fun setUp() { + database = SeedDatabase.open() + SeedDatabase.createTables(database.connection) + insertTestData() + } + + @After + fun tearDown() { + database.close() + } + + private fun insertTestData() { + val sql = + """ + INSERT INTO false_cognates ( + surface_form, lemma, apparent_english, actual_english, real_spanish_for_apparent, + severity, disambiguation_tip, example_es, example_en + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """ + .trimIndent() + + database.connection.prepareStatement(sql).use { stmt -> + // Entry 1: exito + stmt.setString(1, "éxito") + stmt.setString(2, "éxito") + stmt.setString(3, "exit") + stmt.setString(4, "success") + stmt.setString(5, "salida") + stmt.setString(6, "HIGH_RISK") + stmt.setString(7, "Think: 'Exit' is 'salida'; 'éxito' is success.") + stmt.setString(8, "El concierto fue un gran éxito.") + stmt.setString(9, "The concert was a great success.") + stmt.addBatch() + + // Entry 2: exitos (plural) + stmt.setString(1, "éxitos") + stmt.setString(2, "éxito") + stmt.setString(3, "exit") + stmt.setString(4, "success") + stmt.setString(5, "salida") + stmt.setString(6, "HIGH_RISK") + stmt.setString(7, "Think: 'Exit' is 'salida'; 'éxito' is success.") + stmt.setString(8, "Muchos éxitos en tu nuevo trabajo.") + stmt.setString(9, "Much success in your new job.") + stmt.addBatch() + + // Entry 3: carpeta + stmt.setString(1, "carpeta") + stmt.setString(2, "carpeta") + stmt.setString(3, "carpet") + stmt.setString(4, "folder / binder") + stmt.setString(5, "alfombra") + stmt.setString(6, "HIGH_RISK") + stmt.setString(7, "Carpeta holds documents; carpet is alfombra.") + stmt.setString(8, "Guarda los papeles en la carpeta.") + stmt.setString(9, "Keep the papers in the folder.") + stmt.addBatch() + + stmt.executeBatch() + } + } + + @Test + fun getBySurfaceForm_exactAndCaseInsensitive() { + val entry = database.falseCognates.getBySurfaceForm("éxito") + assertNotNull(entry) + assertEquals("éxito", entry?.surfaceForm) + assertEquals("éxito", entry?.lemma) + assertEquals("exit", entry?.apparentEnglish) + assertEquals("success", entry?.actualEnglish) + assertEquals("salida", entry?.realSpanishForApparent) + assertEquals("HIGH_RISK", entry?.severity) + + val caseInsensitive = database.falseCognates.getBySurfaceForm(" CARPETA ") + assertNotNull(caseInsensitive) + assertEquals("carpeta", caseInsensitive?.surfaceForm) + assertEquals("carpet", caseInsensitive?.apparentEnglish) + + val notFound = database.falseCognates.getBySurfaceForm("inexistente") + assertNull(notFound) + } + + @Test + fun getBySurfaceForms_batchQuery() { + val results = database.falseCognates.getBySurfaceForms(setOf("éxito", "carpeta", "no_trap")) + assertEquals(2, results.size) + assertTrue(results.containsKey("éxito")) + assertTrue(results.containsKey("carpeta")) + } + + @Test + fun getByLemma_returnsAllInflectionsOfTrap() { + val entries = database.falseCognates.getByLemma("éxito") + assertEquals(2, entries.size) + assertEquals(listOf("éxito", "éxitos"), entries.map { it.surfaceForm }) + } + + @Test + fun getAll_returnsAllTrapsSorted() { + val all = database.falseCognates.getAll() + assertEquals(3, all.size) + assertEquals("carpeta", all[0].surfaceForm) + assertEquals("éxito", all[1].surfaceForm) + assertEquals("éxitos", all[2].surfaceForm) + } +}