From 2a258e031b9386a60671847dbebf863f7344d0e0 Mon Sep 17 00:00:00 2001 From: Menny Even Danan Date: Sat, 17 Oct 2026 17:15:47 +0000 Subject: [PATCH] [LLM] Implement interactive Toolbox domain engines and OmniBar --- core/toolbox/BUILD.bazel | 84 +++++ .../calque/toolbox/CliticSurgeryEngine.kt | 120 +++++++ .../toolbox/IrregularConjugationData.kt | 322 ++++++++++++++++++ .../calque/toolbox/NumberOdometerEngine.kt | 144 ++++++++ .../calque/toolbox/OmniBarEngine.kt | 115 +++++++ .../calque/toolbox/TenseShifterEngine.kt | 96 ++++++ .../calque/toolbox/ToolboxModels.kt | 159 +++++++++ .../calque/toolbox/TrapDetectorEngine.kt | 73 ++++ .../calque/toolbox/VerbConjugationTables.kt | 142 ++++++++ .../calque/toolbox/WordTransformerEngine.kt | 84 +++++ .../calque/toolbox/CliticSurgeryEngineTest.kt | 104 ++++++ .../toolbox/NumberOdometerEngineTest.kt | 76 +++++ .../calque/toolbox/OmniBarEngineTest.kt | 189 ++++++++++ .../calque/toolbox/TenseShifterEngineTest.kt | 266 +++++++++++++++ .../calque/toolbox/TrapDetectorEngineTest.kt | 98 ++++++ .../toolbox/WordTransformerEngineTest.kt | 96 ++++++ 16 files changed, 2168 insertions(+) create mode 100644 core/toolbox/BUILD.bazel create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngine.kt create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/IrregularConjugationData.kt create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngine.kt create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngine.kt create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngine.kt create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/ToolboxModels.kt create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngine.kt create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/VerbConjugationTables.kt create mode 100644 core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngine.kt create mode 100644 core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngineTest.kt create mode 100644 core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngineTest.kt create mode 100644 core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngineTest.kt create mode 100644 core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngineTest.kt create mode 100644 core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngineTest.kt create mode 100644 core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngineTest.kt diff --git a/core/toolbox/BUILD.bazel b/core/toolbox/BUILD.bazel new file mode 100644 index 0000000..31b211c --- /dev/null +++ b/core/toolbox/BUILD.bazel @@ -0,0 +1,84 @@ +load("@rules_kotlin//kotlin:jvm.bzl", "kt_jvm_library", "kt_jvm_test") + +package(default_visibility = ["//visibility:public"]) + +kt_jvm_library( + name = "toolbox", + srcs = glob(["src/main/kotlin/**/*.kt"]), + visibility = ["//visibility:public"], + deps = [ + "//core/data", + "//core/linguistics", + ], +) + +kt_jvm_test( + name = "trap_detector_engine_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngineTest.kt"], + test_class = "com.anysoftkeyboard.calque.toolbox.TrapDetectorEngineTest", + deps = [ + ":toolbox", + "//core/data", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "word_transformer_engine_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngineTest.kt"], + test_class = "com.anysoftkeyboard.calque.toolbox.WordTransformerEngineTest", + deps = [ + ":toolbox", + "//core/data", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "tense_shifter_engine_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngineTest.kt"], + test_class = "com.anysoftkeyboard.calque.toolbox.TenseShifterEngineTest", + deps = [ + ":toolbox", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "number_odometer_engine_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngineTest.kt"], + test_class = "com.anysoftkeyboard.calque.toolbox.NumberOdometerEngineTest", + deps = [ + ":toolbox", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "clitic_surgery_engine_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngineTest.kt"], + test_class = "com.anysoftkeyboard.calque.toolbox.CliticSurgeryEngineTest", + deps = [ + ":toolbox", + "//core/linguistics", + "@maven//:junit_junit", + ], +) + +kt_jvm_test( + name = "omni_bar_engine_test", + size = "small", + srcs = ["src/test/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngineTest.kt"], + test_class = "com.anysoftkeyboard.calque.toolbox.OmniBarEngineTest", + deps = [ + ":toolbox", + "//core/data", + "//core/linguistics", + "@maven//:junit_junit", + ], +) diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngine.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngine.kt new file mode 100644 index 0000000..414530e --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngine.kt @@ -0,0 +1,120 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.linguistics.CliticDeconstructor +import com.anysoftkeyboard.calque.linguistics.WordLookupProvider +import java.util.Locale + +/** Domain engine for the Clitic Unpacker ("Pronoun Surgery" Sandbox). */ +object CliticSurgeryEngine { + + /** + * Performs pronoun surgery on [word], breaking down root verb and attached enclitics into + * structured, color-coded visual blocks and generating equivalent proclitic structures. + */ + fun performSurgery( + word: String, + lookupProvider: WordLookupProvider, + ): CliticSurgeryResult? { + val breakdown = CliticDeconstructor.deconstruct(word, lookupProvider) ?: return null + val rootVerb = breakdown.rootSurface + val mood = determineVerbMood(rootVerb) + val pronounBlocks = + breakdown.attachedPronouns.map { pronoun -> createPronounBlock(pronoun) } + + val rootBlock = + PronounBlock( + type = PronounBlockType.ROOT_VERB, + text = rootVerb, + roleName = "Root Verb (${breakdown.rootLemma})", + meaning = breakdown.rootEntry.englishGloss, + ) + + val allBlocks = listOf(rootBlock) + pronounBlocks + val procliticEquivalent = + buildProcliticEquivalent(rootVerb, breakdown.attachedPronouns, mood) + val explanation = buildSurgeryExplanation(word, rootVerb, breakdown.attachedPronouns, mood) + + return CliticSurgeryResult( + originalWord = word, + rootVerb = rootVerb, + rootMood = mood, + blocks = allBlocks, + separatedPronouns = breakdown.attachedPronouns, + procliticEquivalent = procliticEquivalent, + explanation = explanation, + ) + } + + private fun determineVerbMood(rootVerb: String): String { + val lower = rootVerb.lowercase(Locale.ROOT) + return when { + lower.endsWith("r") -> "infinitive" + lower.endsWith("ndo") -> "gerund" + else -> "imperative" + } + } + + private fun createPronounBlock(pronoun: String): PronounBlock { + val lower = pronoun.lowercase(Locale.ROOT) + val isDirect = lower in setOf("lo", "la", "los", "las") + val isReflexive = lower == "se" + + val type = + when { + isDirect -> PronounBlockType.DIRECT_PRONOUN + isReflexive -> PronounBlockType.REFLEXIVE_PRONOUN + else -> PronounBlockType.INDIRECT_PRONOUN + } + + val roleName = + when { + isDirect -> "Direct Object" + isReflexive -> "Indirect / Reflexive" + else -> "Indirect Object" + } + + val meaning = getPronounMeaning(lower) + return PronounBlock(type = type, text = pronoun, roleName = roleName, meaning = meaning) + } + + private fun getPronounMeaning(pronoun: String): String { + return when (pronoun) { + "me" -> "to/for me" + "te" -> "to/for you" + "se" -> "to him/her/them (or reflexive)" + "nos" -> "to/for us" + "os" -> "to/for you all" + "le", + "les" -> "to him/her/them" + "lo" -> "him / it (masc.)" + "la" -> "her / it (fem.)" + "los" -> "them (masc.)" + "las" -> "them (fem.)" + else -> pronoun + } + } + + private fun buildProcliticEquivalent( + rootVerb: String, + pronouns: List, + mood: String, + ): String { + val joinedPronouns = pronouns.joinToString(" ") + return when (mood) { + "infinitive" -> "$joinedPronouns va a $rootVerb" + "gerund" -> "$joinedPronouns está $rootVerb" + else -> "No $joinedPronouns $rootVerb" + } + } + + private fun buildSurgeryExplanation( + originalWord: String, + rootVerb: String, + pronouns: List, + mood: String, + ): String { + val pronounCount = if (pronouns.size == 1) "1 pronoun" else "${pronouns.size} pronouns" + val pronounList = pronouns.joinToString(", ") { "'$it'" } + return "'$originalWord' attaches $pronounCount ($pronounList) to the $mood '$rootVerb'." + } +} diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/IrregularConjugationData.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/IrregularConjugationData.kt new file mode 100644 index 0000000..53622d8 --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/IrregularConjugationData.kt @@ -0,0 +1,322 @@ +package com.anysoftkeyboard.calque.toolbox + +/** Irregular conjugation data for common Spanish verbs. */ +internal object IrregularConjugationData { + + val IRREGULARS: Map>> = + mapOf( + "ser" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "soy", + SubjectPronoun.TU to "eres", + SubjectPronoun.EL_ELLA_USTED to "es", + SubjectPronoun.NOSOTROS to "somos", + SubjectPronoun.ELLOS_USTEDES to "son", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "fui", + SubjectPronoun.TU to "fuiste", + SubjectPronoun.EL_ELLA_USTED to "fue", + SubjectPronoun.NOSOTROS to "fuimos", + SubjectPronoun.ELLOS_USTEDES to "fueron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "era", + SubjectPronoun.TU to "eras", + SubjectPronoun.EL_ELLA_USTED to "era", + SubjectPronoun.NOSOTROS to "éramos", + SubjectPronoun.ELLOS_USTEDES to "eran", + ), + ), + "estar" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "estoy", + SubjectPronoun.TU to "estás", + SubjectPronoun.EL_ELLA_USTED to "está", + SubjectPronoun.NOSOTROS to "estamos", + SubjectPronoun.ELLOS_USTEDES to "están", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "estuve", + SubjectPronoun.TU to "estuviste", + SubjectPronoun.EL_ELLA_USTED to "estuvo", + SubjectPronoun.NOSOTROS to "estuvimos", + SubjectPronoun.ELLOS_USTEDES to "estuvieron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "estaba", + SubjectPronoun.TU to "estabas", + SubjectPronoun.EL_ELLA_USTED to "estaba", + SubjectPronoun.NOSOTROS to "estábamos", + SubjectPronoun.ELLOS_USTEDES to "estaban", + ), + ), + "ir" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "voy", + SubjectPronoun.TU to "vas", + SubjectPronoun.EL_ELLA_USTED to "va", + SubjectPronoun.NOSOTROS to "vamos", + SubjectPronoun.ELLOS_USTEDES to "van", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "fui", + SubjectPronoun.TU to "fuiste", + SubjectPronoun.EL_ELLA_USTED to "fue", + SubjectPronoun.NOSOTROS to "fuimos", + SubjectPronoun.ELLOS_USTEDES to "fueron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "iba", + SubjectPronoun.TU to "ibas", + SubjectPronoun.EL_ELLA_USTED to "iba", + SubjectPronoun.NOSOTROS to "íbamos", + SubjectPronoun.ELLOS_USTEDES to "iban", + ), + ), + "tener" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "tengo", + SubjectPronoun.TU to "tienes", + SubjectPronoun.EL_ELLA_USTED to "tiene", + SubjectPronoun.NOSOTROS to "tenemos", + SubjectPronoun.ELLOS_USTEDES to "tienen", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "tuve", + SubjectPronoun.TU to "tuviste", + SubjectPronoun.EL_ELLA_USTED to "tuvo", + SubjectPronoun.NOSOTROS to "tuvimos", + SubjectPronoun.ELLOS_USTEDES to "tuvieron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "tenía", + SubjectPronoun.TU to "tenías", + SubjectPronoun.EL_ELLA_USTED to "tenía", + SubjectPronoun.NOSOTROS to "teníamos", + SubjectPronoun.ELLOS_USTEDES to "tenían", + ), + ), + "hacer" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "hago", + SubjectPronoun.TU to "haces", + SubjectPronoun.EL_ELLA_USTED to "hace", + SubjectPronoun.NOSOTROS to "hacemos", + SubjectPronoun.ELLOS_USTEDES to "hacen", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "hice", + SubjectPronoun.TU to "hiciste", + SubjectPronoun.EL_ELLA_USTED to "hizo", + SubjectPronoun.NOSOTROS to "hicimos", + SubjectPronoun.ELLOS_USTEDES to "hicieron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "hacía", + SubjectPronoun.TU to "hacías", + SubjectPronoun.EL_ELLA_USTED to "hacía", + SubjectPronoun.NOSOTROS to "hacíamos", + SubjectPronoun.ELLOS_USTEDES to "hacían", + ), + ), + "poder" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "puedo", + SubjectPronoun.TU to "puedes", + SubjectPronoun.EL_ELLA_USTED to "puede", + SubjectPronoun.NOSOTROS to "podemos", + SubjectPronoun.ELLOS_USTEDES to "pueden", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "pude", + SubjectPronoun.TU to "pudiste", + SubjectPronoun.EL_ELLA_USTED to "pudo", + SubjectPronoun.NOSOTROS to "pudimos", + SubjectPronoun.ELLOS_USTEDES to "pudieron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "podía", + SubjectPronoun.TU to "podías", + SubjectPronoun.EL_ELLA_USTED to "podía", + SubjectPronoun.NOSOTROS to "podíamos", + SubjectPronoun.ELLOS_USTEDES to "podían", + ), + ), + "querer" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "quiero", + SubjectPronoun.TU to "quieres", + SubjectPronoun.EL_ELLA_USTED to "quiere", + SubjectPronoun.NOSOTROS to "queremos", + SubjectPronoun.ELLOS_USTEDES to "quieren", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "quise", + SubjectPronoun.TU to "quisiste", + SubjectPronoun.EL_ELLA_USTED to "quiso", + SubjectPronoun.NOSOTROS to "quisimos", + SubjectPronoun.ELLOS_USTEDES to "quisieron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "quería", + SubjectPronoun.TU to "querías", + SubjectPronoun.EL_ELLA_USTED to "quería", + SubjectPronoun.NOSOTROS to "queríamos", + SubjectPronoun.ELLOS_USTEDES to "querían", + ), + ), + "ver" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "veo", + SubjectPronoun.TU to "ves", + SubjectPronoun.EL_ELLA_USTED to "ve", + SubjectPronoun.NOSOTROS to "vemos", + SubjectPronoun.ELLOS_USTEDES to "ven", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "vi", + SubjectPronoun.TU to "viste", + SubjectPronoun.EL_ELLA_USTED to "vio", + SubjectPronoun.NOSOTROS to "vimos", + SubjectPronoun.ELLOS_USTEDES to "vieron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "veía", + SubjectPronoun.TU to "veías", + SubjectPronoun.EL_ELLA_USTED to "veía", + SubjectPronoun.NOSOTROS to "veíamos", + SubjectPronoun.ELLOS_USTEDES to "veían", + ), + ), + "saber" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "sé", + SubjectPronoun.TU to "sabes", + SubjectPronoun.EL_ELLA_USTED to "sabe", + SubjectPronoun.NOSOTROS to "sabemos", + SubjectPronoun.ELLOS_USTEDES to "saben", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "supe", + SubjectPronoun.TU to "supiste", + SubjectPronoun.EL_ELLA_USTED to "supo", + SubjectPronoun.NOSOTROS to "supimos", + SubjectPronoun.ELLOS_USTEDES to "supieron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "sabía", + SubjectPronoun.TU to "sabías", + SubjectPronoun.EL_ELLA_USTED to "sabía", + SubjectPronoun.NOSOTROS to "sabíamos", + SubjectPronoun.ELLOS_USTEDES to "sabían", + ), + ), + "decir" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "digo", + SubjectPronoun.TU to "dices", + SubjectPronoun.EL_ELLA_USTED to "dice", + SubjectPronoun.NOSOTROS to "decimos", + SubjectPronoun.ELLOS_USTEDES to "dicen", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "dije", + SubjectPronoun.TU to "dijiste", + SubjectPronoun.EL_ELLA_USTED to "dijo", + SubjectPronoun.NOSOTROS to "dijimos", + SubjectPronoun.ELLOS_USTEDES to "dijeron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "decía", + SubjectPronoun.TU to "decías", + SubjectPronoun.EL_ELLA_USTED to "decía", + SubjectPronoun.NOSOTROS to "decíamos", + SubjectPronoun.ELLOS_USTEDES to "decían", + ), + ), + "dar" to + mapOf( + ReadingTense.PRESENT to + mapOf( + SubjectPronoun.YO to "doy", + SubjectPronoun.TU to "das", + SubjectPronoun.EL_ELLA_USTED to "da", + SubjectPronoun.NOSOTROS to "damos", + SubjectPronoun.ELLOS_USTEDES to "dan", + ), + ReadingTense.PAST_COMPLETED to + mapOf( + SubjectPronoun.YO to "di", + SubjectPronoun.TU to "diste", + SubjectPronoun.EL_ELLA_USTED to "dio", + SubjectPronoun.NOSOTROS to "dimos", + SubjectPronoun.ELLOS_USTEDES to "dieron", + ), + ReadingTense.PAST_ONGOING to + mapOf( + SubjectPronoun.YO to "daba", + SubjectPronoun.TU to "dabas", + SubjectPronoun.EL_ELLA_USTED to "daba", + SubjectPronoun.NOSOTROS to "dábamos", + SubjectPronoun.ELLOS_USTEDES to "daban", + ), + ), + ) + + fun getConjugation( + lemma: String, + pronoun: SubjectPronoun, + tense: ReadingTense, + ): ConjugatedVerbForm? { + val form = IRREGULARS[lemma]?.get(tense)?.get(pronoun) ?: return null + return ConjugatedVerbForm( + lemma = lemma, + pronoun = pronoun, + tense = tense, + fullForm = form, + stem = lemma, + suffix = "", + ) + } +} diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngine.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngine.kt new file mode 100644 index 0000000..2340f85 --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngine.kt @@ -0,0 +1,144 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.linguistics.GrammaticalGender +import com.anysoftkeyboard.calque.linguistics.NumberConverter + +/** Domain engine for the Number Odometer (Counting, Dates & Time Sandbox). */ +object NumberOdometerEngine { + + private const val MAX_SUPPORTED_NUMBER = 999_999L + private const val MAX_HOUR = 23 + private const val MAX_MINUTE = 59 + private const val MAX_MONTH = 12 + private const val NOON = 12 + private const val HALF_HOUR = 30 + private const val QUARTER = 15 + private const val THREE_QUARTERS = 45 + private const val MINUTES_PER_HOUR = 60 + + private const val EARLY_MORNING_END = 5 + private const val MORNING_END = 11 + private const val AFTERNOON_END = 19 + + private val MONTH_NAMES = + listOf( + "", + "enero", + "febrero", + "marzo", + "abril", + "mayo", + "junio", + "julio", + "agosto", + "septiembre", + "octubre", + "noviembre", + "diciembre", + ) + + private val HOUR_WORDS = + listOf( + "", + "una", + "dos", + "tres", + "cuatro", + "cinco", + "seis", + "siete", + "ocho", + "nueve", + "diez", + "once", + "doce", + ) + + /** Converts [number] (0..999,999) to written Spanish with masculine and feminine agreement. */ + fun formatNumberWithAgreement(number: Long): NumberAgreementResult { + val clamped = number.coerceIn(0L, MAX_SUPPORTED_NUMBER).toInt() + val masc = NumberConverter.convert(clamped, GrammaticalGender.MASCULINE, isApocope = true) + val fem = NumberConverter.convert(clamped, GrammaticalGender.FEMININE, isApocope = false) + return NumberAgreementResult( + number = clamped.toLong(), + masculineText = masc, + feminineText = fem, + ) + } + + /** Formats [hour] (0..23) and [minute] (0..59) into conversational Spanish clock time. */ + fun formatClockTime(hour: Int, minute: Int): ClockTimeResult { + val normHour = hour.coerceIn(0, MAX_HOUR) + val normMin = minute.coerceIn(0, MAX_MINUTE) + val period = getPeriodOfDay(normHour) + + if (normHour == NOON && normMin == 0) { + return ClockTimeResult(normHour, normMin, "Es mediodía", period) + } + if (normHour == 0 && normMin == 0) { + return ClockTimeResult(normHour, normMin, "Es medianoche", period) + } + + val clock12Hour = if (normHour % NOON == 0) NOON else normHour % NOON + val phrase = buildTimePhrase(clock12Hour, normMin) + return ClockTimeResult(normHour, normMin, "$phrase $period", period) + } + + /** Formats a calendar date [day] (1..31), [month] (1..12), and optional [year]. */ + fun formatDate(day: Int, month: Int, year: Int? = null): DatePhraseResult { + val validMonth = month.coerceIn(1, MAX_MONTH) + val monthName = MONTH_NAMES[validMonth] + val dayPhrase = if (day == 1) "el primero" else "el $day" + + val phrase = + if (year != null) { + "$dayPhrase de $monthName de $year" + } else { + "$dayPhrase de $monthName" + } + + return DatePhraseResult(day, validMonth, year, phrase) + } + + private fun hourPrefix(hour: Int): String = + if (hour == 1) "Es la una" else "Son las ${hourToWord(hour)}" + + private fun buildTimePhrase(clockHour: Int, minute: Int): String { + return when { + minute == 0 -> "${hourPrefix(clockHour)} en punto" + minute == QUARTER -> "${hourPrefix(clockHour)} y cuarto" + minute == HALF_HOUR -> "${hourPrefix(clockHour)} y media" + minute < HALF_HOUR -> { + val minText = NumberConverter.convert(minute, GrammaticalGender.FEMININE) + "${hourPrefix(clockHour)} y $minText" + } + minute == THREE_QUARTERS -> { + val nextHour = if (clockHour == NOON) 1 else clockHour + 1 + "${hourPrefix(nextHour)} menos cuarto" + } + else -> { + val nextHour = if (clockHour == NOON) 1 else clockHour + 1 + val diff = MINUTES_PER_HOUR - minute + val minText = NumberConverter.convert(diff, GrammaticalGender.FEMININE) + "${hourPrefix(nextHour)} menos $minText" + } + } + } + + private fun hourToWord(hour: Int): String { + return if (hour in 1..NOON) { + HOUR_WORDS[hour] + } else { + hour.toString() + } + } + + private fun getPeriodOfDay(hour: Int): String { + return when { + hour in 1..EARLY_MORNING_END -> "de la madrugada" + hour in (EARLY_MORNING_END + 1)..MORNING_END -> "de la mañana" + hour in (MORNING_END + 1)..AFTERNOON_END -> "de la tarde" + else -> "de la noche" + } + } +} diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngine.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngine.kt new file mode 100644 index 0000000..12a2479 --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngine.kt @@ -0,0 +1,115 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.data.seed.CognateRulesRepository +import com.anysoftkeyboard.calque.data.seed.FalseCognatesRepository +import com.anysoftkeyboard.calque.linguistics.WordLookupProvider +import java.util.Locale + +/** + * Domain engine powering the Toolbox Smart Omni-Bar. + * + * Routes user queries to Numbers/Times, Cognate Suffix Rules, False Cognates, Clitic Surgery, or + * Dictionary Lookups. + */ +class OmniBarEngine( + private val cognateRulesRepository: CognateRulesRepository, + private val falseCognatesRepository: FalseCognatesRepository, + private val wordLookupProvider: WordLookupProvider, +) { + + private companion object { + private const val MAX_VALID_HOUR = 23 + private const val MAX_VALID_MINUTE = 59 + private const val MAX_SUPPORTED_NUMBER = 999_999L + private const val MINUTES_PER_HOUR = 60 + } + + /** Parses and routes [query] to the most appropriate [OmniBarResult]. */ + fun route(query: String): OmniBarResult { + val trimmed = query.trim() + if (trimmed.isEmpty()) return OmniBarResult.None(trimmed) + + return findCandidateResult(trimmed) ?: OmniBarResult.None(trimmed) + } + + private fun findCandidateResult(input: String): OmniBarResult? { + val lower = input.lowercase(Locale.ROOT) + return tryRouteNumberOrTime(input) + ?: tryRouteSuffixRule(input) + ?: tryRouteFalseCognate(input, lower) + ?: tryRouteClitic(input, lower) + ?: tryRouteWord(input, lower) + } + + private fun tryRouteFalseCognate(input: String, lower: String): OmniBarResult? { + val trapCard = falseCognatesRepository.getBySurfaceForm(lower) ?: return null + return OmniBarResult.FalseCognateResult(input, trapCard) + } + + private fun tryRouteClitic(input: String, lower: String): OmniBarResult? { + val surgery = CliticSurgeryEngine.performSurgery(lower, wordLookupProvider) ?: return null + return OmniBarResult.CliticResult(input, surgery) + } + + private fun tryRouteWord(input: String, lower: String): OmniBarResult? { + val word = wordLookupProvider.lookupSingleWord(lower) ?: return null + return OmniBarResult.WordLookupResult(input, word) + } + + private fun tryRouteNumberOrTime(input: String): OmniBarResult? { + val clockResult = tryRouteClockTime(input) + if (clockResult != null) return clockResult + + val parsedLong = input.toLongOrNull() + if (parsedLong != null && parsedLong in 0L..MAX_SUPPORTED_NUMBER) { + val agreement = NumberOdometerEngine.formatNumberWithAgreement(parsedLong) + return OmniBarResult.NumberResult( + input = input, + number = parsedLong, + masculineWords = agreement.masculineText, + feminineWords = agreement.feminineText, + ) + } + + return null + } + + private fun tryRouteClockTime(input: String): OmniBarResult? { + val timeParts = input.split(":") + if (timeParts.size != 2) return null + + val h = timeParts[0].toIntOrNull() + val m = timeParts[1].toIntOrNull() + if (!isValidTime(h, m)) return null + + val timeResult = NumberOdometerEngine.formatClockTime(h!!, m!!) + return OmniBarResult.NumberResult( + input = input, + number = (h * MINUTES_PER_HOUR + m).toLong(), + masculineWords = timeResult.phrase, + feminineWords = timeResult.phrase, + ) + } + + private fun isValidTime(h: Int?, m: Int?): Boolean { + if (h == null || m == null) return false + return h in 0..MAX_VALID_HOUR && m in 0..MAX_VALID_MINUTE + } + + private fun tryRouteSuffixRule(input: String): OmniBarResult? { + val cleanSuffix = if (input.startsWith("-")) input else "-$input" + val allRules = cognateRulesRepository.getAll() + + val matchingRule = + allRules.firstOrNull { rule -> + rule.englishSuffix.equals(cleanSuffix, ignoreCase = true) || + rule.spanishSuffix.equals(cleanSuffix, ignoreCase = true) + } + + return if (matchingRule != null) { + OmniBarResult.SuffixRuleResult(input, matchingRule) + } else { + null + } + } +} diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngine.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngine.kt new file mode 100644 index 0000000..05f3958 --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngine.kt @@ -0,0 +1,96 @@ +package com.anysoftkeyboard.calque.toolbox + +import java.util.Locale + +/** Domain engine for the Tense Shifter (Verb Timeline Sandbox). */ +object TenseShifterEngine { + + private const val MIN_LEMMA_LENGTH = 3 + private const val VERB_ENDING_LENGTH = 2 + + /** Returns the list of standard supported verbs in the Tense Shifter sandbox. */ + fun supportedVerbs(): List = + listOf( + "hablar", + "comer", + "vivir", + "ser", + "estar", + "ir", + "tener", + "hacer", + "poder", + "querer", + "ver", + "saber", + "decir", + "dar", + ) + + /** Conjugates [verb] for the specified [pronoun] and [tense]. */ + fun conjugate( + verb: String, + pronoun: SubjectPronoun, + tense: ReadingTense, + ): ConjugatedVerbForm { + val lower = verb.lowercase(Locale.ROOT).trim() + + val irregularForm = IrregularConjugationData.getConjugation(lower, pronoun, tense) + if (irregularForm != null) { + return irregularForm + } + + return VerbConjugationTables.conjugateRegular(lower, pronoun, tense) + } + + /** + * Replaces the verb in [sentence] corresponding to [lemma] with its conjugated form in [tense] + * and [pronoun]. + */ + fun shiftSentence( + sentence: String, + lemma: String, + pronoun: SubjectPronoun, + tense: ReadingTense, + ): ShiftedSentenceResult { + val newForm = conjugate(lemma, pronoun, tense) + val tokens = sentence.split(Regex("(?<=\\s)|(?=\\s)|(?=[.,!?;:])|(?<=[.,!?;:])")) + + val replacedTokens = mutableListOf() + var foundVerb = false + var originalVerb = lemma + + for (token in tokens) { + val cleanToken = token.lowercase(Locale.ROOT) + if (!foundVerb && matchesVerbStemOrLemma(cleanToken, lemma)) { + originalVerb = token + replacedTokens.add(newForm.fullForm) + foundVerb = true + } else { + replacedTokens.add(token) + } + } + + val shiftedSentence = + if (foundVerb) { + replacedTokens.joinToString("") + } else { + "$sentence -> ${newForm.fullForm}" + } + + return ShiftedSentenceResult( + originalSentence = sentence, + shiftedSentence = shiftedSentence, + originalVerb = originalVerb, + newVerb = newForm.fullForm, + tense = tense, + pronoun = pronoun, + ) + } + + private fun matchesVerbStemOrLemma(token: String, lemma: String): Boolean { + if (token.isEmpty() || lemma.length < MIN_LEMMA_LENGTH) return false + val stem = lemma.dropLast(VERB_ENDING_LENGTH) + return token == lemma || token.startsWith(stem) + } +} diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/ToolboxModels.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/ToolboxModels.kt new file mode 100644 index 0000000..e6785ed --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/ToolboxModels.kt @@ -0,0 +1,159 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.data.models.CognateRule +import com.anysoftkeyboard.calque.linguistics.DictionaryEntry +import com.anysoftkeyboard.calque.linguistics.FalseCognateEntry + +/** Result model for Smart Omni-Bar queries. */ +sealed interface OmniBarResult { + val input: String + + data class NumberResult( + override val input: String, + val number: Long, + val masculineWords: String, + val feminineWords: String, + ) : OmniBarResult + + data class SuffixRuleResult( + override val input: String, + val rule: CognateRule, + ) : OmniBarResult + + data class FalseCognateResult( + override val input: String, + val card: FalseCognateEntry, + ) : OmniBarResult + + data class CliticResult( + override val input: String, + val surgery: CliticSurgeryResult, + ) : OmniBarResult + + data class WordLookupResult( + override val input: String, + val word: DictionaryEntry, + ) : OmniBarResult + + data class None( + override val input: String, + ) : OmniBarResult +} + +/** Filter category for the Trap Detector false cognates sandbox. */ +enum class TrapFilter { + ALL, + HIGH_RISK, + MILD_NUANCE, + SEEN_IN_READING, +} + +/** 2-choice comprehension check question for "Spot the Trap" quiz mode. */ +data class TrapQuizQuestion( + val sentenceEs: String, + val targetWord: String, + val prompt: String, + val options: List, + val correctOptionIndex: Int, + val explanation: String, +) + +/** Result of a cognate suffix bridge transformation. */ +data class TransformedCognateWord( + val sourceWord: String, + val resultWord: String, + val ruleId: String, + val stem: String, + val sourceSuffix: String, + val targetSuffix: String, + val genderRule: String?, + val spellingNote: String, +) + +/** The 4 core reading tenses featured in the Tense Shifter sandbox. */ +enum class ReadingTense(val displayName: String, val category: String) { + PAST_COMPLETED("Pretérito", "Past Completed"), + PAST_ONGOING("Imperfecto", "Past Ongoing"), + PRESENT("Presente", "Present"), + NEAR_FUTURE("Ir a + Infinitivo", "Near Future"), +} + +/** The 5 subject pronoun categories used for reading verb conjugations. */ +enum class SubjectPronoun(val displayPronoun: String) { + YO("yo"), + TU("tú"), + EL_ELLA_USTED("él / ella / usted"), + NOSOTROS("nosotros / nosotras"), + ELLOS_USTEDES("ellos / ellas / ustedes"), +} + +/** Conjugated verb details with stem/ending decomposition. */ +data class ConjugatedVerbForm( + val lemma: String, + val pronoun: SubjectPronoun, + val tense: ReadingTense, + val fullForm: String, + val stem: String, + val suffix: String, + val auxiliary: String? = null, +) + +/** Sentence morphing result when shifting tense in a context sentence. */ +data class ShiftedSentenceResult( + val originalSentence: String, + val shiftedSentence: String, + val originalVerb: String, + val newVerb: String, + val tense: ReadingTense, + val pronoun: SubjectPronoun, +) + +/** Number conversion with masculine and feminine agreement forms. */ +data class NumberAgreementResult( + val number: Long, + val masculineText: String, + val feminineText: String, +) + +/** Spelled-out Spanish clock time phrase. */ +data class ClockTimeResult( + val hour: Int, + val minute: Int, + val phrase: String, + val periodOfDay: String, +) + +/** Spelled-out Spanish date phrase. */ +data class DatePhraseResult( + val day: Int, + val month: Int, + val year: Int?, + val phrase: String, +) + +/** Color-coded functional role of a block in pronoun surgery. */ +enum class PronounBlockType { + ROOT_VERB, + INDIRECT_PRONOUN, + DIRECT_PRONOUN, + REFLEXIVE_PRONOUN, +} + +/** Single visual block in the pronoun surgery workbench. */ +data class PronounBlock( + val type: PronounBlockType, + val text: String, + val roleName: String, + val meaning: String, +) + +/** Result of pronoun surgery on a compound verb with enclitics. */ +data class CliticSurgeryResult( + val originalWord: String, + val rootVerb: String, + val rootMood: String, + val blocks: List, + val separatedPronouns: List, + val procliticEquivalent: String, + val explanation: String, +) diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngine.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngine.kt new file mode 100644 index 0000000..b607483 --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngine.kt @@ -0,0 +1,73 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.linguistics.FalseCognateEntry +import java.util.Locale + +/** Domain engine for the Trap Detector (False Cognates Sandbox). */ +object TrapDetectorEngine { + + /** Filters a collection of [traps] according to [filter] mode and [seenLemmas]. */ + fun filterTraps( + traps: List, + filter: TrapFilter, + seenLemmas: Set = emptySet(), + ): List { + val normalizedSeen = seenLemmas.map { it.lowercase(Locale.ROOT) }.toSet() + return when (filter) { + TrapFilter.ALL -> traps + TrapFilter.HIGH_RISK -> + traps.filter { it.severity.equals("HIGH_RISK", ignoreCase = true) } + TrapFilter.MILD_NUANCE -> + traps.filter { it.severity.equals("MILD_NUANCE", ignoreCase = true) } + TrapFilter.SEEN_IN_READING -> + traps.filter { + normalizedSeen.contains(it.lemma.lowercase(Locale.ROOT)) || + normalizedSeen.contains(it.surfaceForm.lowercase(Locale.ROOT)) + } + } + } + + /** + * Generates a 2-choice "Spot the Trap" quiz question from a [FalseCognateEntry]. + * + * @param trap The false cognate card to turn into a question. + * @param correctFirst Whether the correct answer is the first option (for deterministic + * testing). + */ + fun createQuizQuestion( + trap: FalseCognateEntry, + correctFirst: Boolean = true, + ): TrapQuizQuestion { + val correct = trap.actualEnglish + val distractor = trap.apparentEnglish + val options = if (correctFirst) listOf(correct, distractor) else listOf(distractor, correct) + val correctIndex = if (correctFirst) 0 else 1 + + val sentence = + if (trap.exampleEs.isNotBlank()) { + trap.exampleEs + } else { + "Cuidado con la palabra '${trap.surfaceForm}'." + } + + return TrapQuizQuestion( + sentenceEs = sentence, + targetWord = trap.surfaceForm, + prompt = "What does \"${trap.surfaceForm}\" mean in this sentence?", + options = options, + correctOptionIndex = correctIndex, + explanation = trap.disambiguationTip, + ) + } + + /** Generates quiz questions for all provided [traps]. */ + fun generateQuiz( + traps: List, + alternateAnswers: Boolean = true, + ): List { + return traps.mapIndexed { index, card -> + val correctFirst = if (alternateAnswers) index % 2 == 0 else true + createQuizQuestion(card, correctFirst = correctFirst) + } + } +} diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/VerbConjugationTables.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/VerbConjugationTables.kt new file mode 100644 index 0000000..1e66965 --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/VerbConjugationTables.kt @@ -0,0 +1,142 @@ +package com.anysoftkeyboard.calque.toolbox + +/** Conjugation lookup tables and regular rule inflector for Spanish reading tenses. */ +object VerbConjugationTables { + + private const val MIN_LEMMA_LENGTH = 3 + + /** Near future auxiliary form for "ir a + [lemma]" by subject pronoun. */ + fun getNearFutureAuxiliary(pronoun: SubjectPronoun): String = + when (pronoun) { + SubjectPronoun.YO -> "voy a" + SubjectPronoun.TU -> "vas a" + SubjectPronoun.EL_ELLA_USTED -> "va a" + SubjectPronoun.NOSOTROS -> "vamos a" + SubjectPronoun.ELLOS_USTEDES -> "van a" + } + + /** Conjugates a regular verb lemma into the requested reading tense and subject pronoun. */ + fun conjugateRegular( + lemma: String, + pronoun: SubjectPronoun, + tense: ReadingTense, + ): ConjugatedVerbForm { + if (tense == ReadingTense.NEAR_FUTURE) { + val aux = getNearFutureAuxiliary(pronoun) + return ConjugatedVerbForm( + lemma = lemma, + pronoun = pronoun, + tense = tense, + fullForm = "$aux $lemma", + stem = lemma, + suffix = "", + auxiliary = aux, + ) + } + + if (lemma.length < MIN_LEMMA_LENGTH) { + return ConjugatedVerbForm( + lemma = lemma, + pronoun = pronoun, + tense = tense, + fullForm = lemma, + stem = lemma, + suffix = "", + ) + } + + val ending = lemma.takeLast(2) + val stem = lemma.dropLast(2) + + val suffix = + when (tense) { + ReadingTense.PRESENT -> getRegularPresentEnding(ending, pronoun) + ReadingTense.PAST_COMPLETED -> getRegularPreteriteEnding(ending, pronoun) + ReadingTense.PAST_ONGOING -> getRegularImperfectEnding(ending, pronoun) + ReadingTense.NEAR_FUTURE -> "" + } + + return ConjugatedVerbForm( + lemma = lemma, + pronoun = pronoun, + tense = tense, + fullForm = stem + suffix, + stem = stem, + suffix = suffix, + ) + } + + private fun getArPresentEnding(pronoun: SubjectPronoun): String = + when (pronoun) { + SubjectPronoun.YO -> "o" + SubjectPronoun.TU -> "as" + SubjectPronoun.EL_ELLA_USTED -> "a" + SubjectPronoun.NOSOTROS -> "amos" + SubjectPronoun.ELLOS_USTEDES -> "an" + } + + private fun getErPresentEnding(pronoun: SubjectPronoun): String = + when (pronoun) { + SubjectPronoun.YO -> "o" + SubjectPronoun.TU -> "es" + SubjectPronoun.EL_ELLA_USTED -> "e" + SubjectPronoun.NOSOTROS -> "emos" + SubjectPronoun.ELLOS_USTEDES -> "en" + } + + private fun getIrPresentEnding(pronoun: SubjectPronoun): String = + when (pronoun) { + SubjectPronoun.YO -> "o" + SubjectPronoun.TU -> "es" + SubjectPronoun.EL_ELLA_USTED -> "e" + SubjectPronoun.NOSOTROS -> "imos" + SubjectPronoun.ELLOS_USTEDES -> "en" + } + + private fun getRegularPresentEnding(ending: String, pronoun: SubjectPronoun): String = + when (ending) { + "ar" -> getArPresentEnding(pronoun) + "er" -> getErPresentEnding(pronoun) + else -> getIrPresentEnding(pronoun) + } + + private fun getRegularPreteriteEnding(ending: String, pronoun: SubjectPronoun): String = + when (ending) { + "ar" -> + when (pronoun) { + SubjectPronoun.YO -> "é" + SubjectPronoun.TU -> "aste" + SubjectPronoun.EL_ELLA_USTED -> "ó" + SubjectPronoun.NOSOTROS -> "amos" + SubjectPronoun.ELLOS_USTEDES -> "aron" + } + else -> + when (pronoun) { + SubjectPronoun.YO -> "í" + SubjectPronoun.TU -> "iste" + SubjectPronoun.EL_ELLA_USTED -> "ió" + SubjectPronoun.NOSOTROS -> "imos" + SubjectPronoun.ELLOS_USTEDES -> "ieron" + } + } + + private fun getRegularImperfectEnding(ending: String, pronoun: SubjectPronoun): String = + when (ending) { + "ar" -> + when (pronoun) { + SubjectPronoun.YO -> "aba" + SubjectPronoun.TU -> "abas" + SubjectPronoun.EL_ELLA_USTED -> "aba" + SubjectPronoun.NOSOTROS -> "ábamos" + SubjectPronoun.ELLOS_USTEDES -> "aban" + } + else -> + when (pronoun) { + SubjectPronoun.YO -> "ía" + SubjectPronoun.TU -> "ías" + SubjectPronoun.EL_ELLA_USTED -> "ía" + SubjectPronoun.NOSOTROS -> "íamos" + SubjectPronoun.ELLOS_USTEDES -> "ían" + } + } +} diff --git a/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngine.kt b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngine.kt new file mode 100644 index 0000000..348d196 --- /dev/null +++ b/core/toolbox/src/main/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngine.kt @@ -0,0 +1,84 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.data.models.CognateRule +import java.util.Locale + +/** Domain engine for the Word Transformer (Cognate Suffix Bridge Sandbox). */ +object WordTransformerEngine { + + /** Transforms an English word to its Spanish cognate counterpart using [rules]. */ + fun transformEnglishToSpanish( + englishWord: String, + rules: List, + ): TransformedCognateWord? { + val normalized = englishWord.trim().lowercase(Locale.ROOT) + // Find longest matching english suffix + val matchingRule = + rules + .filter { normalized.endsWith(it.englishSuffix.removePrefix("-")) } + .maxByOrNull { it.englishSuffix.length } ?: return null + + val rawSuffix = matchingRule.englishSuffix.removePrefix("-") + val stem = normalized.removeSuffix(rawSuffix) + if (stem.length < 2) return null + + val adjustedStem = adjustEnglishStemForSpanish(stem) + val spanishSuffix = matchingRule.spanishSuffix.removePrefix("-") + val result = adjustedStem + spanishSuffix + + return TransformedCognateWord( + sourceWord = englishWord, + resultWord = result, + ruleId = matchingRule.ruleId, + stem = stem, + sourceSuffix = matchingRule.englishSuffix, + targetSuffix = matchingRule.spanishSuffix, + genderRule = matchingRule.genderRule, + spellingNote = matchingRule.spellingNote, + ) + } + + /** Transforms a Spanish word to its English cognate counterpart using [rules]. */ + fun transformSpanishToEnglish( + spanishWord: String, + rules: List, + ): TransformedCognateWord? { + val normalized = spanishWord.trim().lowercase(Locale.ROOT) + // Find longest matching spanish suffix + val matchingRule = + rules + .filter { normalized.endsWith(it.spanishSuffix.removePrefix("-")) } + .maxByOrNull { it.spanishSuffix.length } ?: return null + + val rawSuffix = matchingRule.spanishSuffix.removePrefix("-") + val stem = normalized.removeSuffix(rawSuffix) + if (stem.length < 2) return null + + val englishSuffix = matchingRule.englishSuffix.removePrefix("-") + val result = stem + englishSuffix + + return TransformedCognateWord( + sourceWord = spanishWord, + resultWord = result, + ruleId = matchingRule.ruleId, + stem = stem, + sourceSuffix = matchingRule.spanishSuffix, + targetSuffix = matchingRule.englishSuffix, + genderRule = matchingRule.genderRule, + spellingNote = matchingRule.spellingNote, + ) + } + + private fun adjustEnglishStemForSpanish(stem: String): String { + return stem + .replace("ph", "f") + .replace("th", "t") + .replace("tt", "t") + .replace("ss", "s") + .replace("mm", "m") + .replace("pp", "p") + .replace("ff", "f") + .replace("bb", "b") + .replace("dd", "d") + } +} diff --git a/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngineTest.kt b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngineTest.kt new file mode 100644 index 0000000..592eb82 --- /dev/null +++ b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/CliticSurgeryEngineTest.kt @@ -0,0 +1,104 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.linguistics.DictionaryEntry +import com.anysoftkeyboard.calque.linguistics.FalseCognateEntry +import com.anysoftkeyboard.calque.linguistics.UserWordStatus +import com.anysoftkeyboard.calque.linguistics.WordLookupProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class CliticSurgeryEngineTest { + + private val testLookupProvider = + object : WordLookupProvider { + private val dictionary = + mapOf( + "explicar" to + DictionaryEntry( + surfaceForm = "explicar", + lemma = "explicar", + pos = "verb", + grammarInfo = "inf", + englishGloss = "to explain", + isCognate = true, + frequencyRank = 450, + ), + "comprar" to + DictionaryEntry( + surfaceForm = "comprar", + lemma = "comprar", + pos = "verb", + grammarInfo = "inf", + englishGloss = "to buy", + isCognate = false, + frequencyRank = 300, + ), + "diciendo" to + DictionaryEntry( + surfaceForm = "diciendo", + lemma = "decir", + pos = "verb", + grammarInfo = "ger", + englishGloss = "saying / telling", + isCognate = false, + frequencyRank = 15, + ), + "casa" to + DictionaryEntry( + surfaceForm = "casa", + lemma = "casa", + pos = "noun", + grammarInfo = "fem", + englishGloss = "house", + isCognate = false, + frequencyRank = 80, + ), + ) + + override fun batchLookupDictionary(words: Set): Map = + words.mapNotNull { w -> dictionary[w]?.let { w to it } }.toMap() + + override fun batchLookupFalseCognates( + words: Set + ): Map = emptyMap() + + override fun batchLookupUserState(lemmas: Set): Map = + emptyMap() + + override fun lookupSingleWord(word: String): DictionaryEntry? = dictionary[word] + } + + @Test + fun performSurgery_infinitiveWithTwoClitics() { + val result = CliticSurgeryEngine.performSurgery("explicárselo", testLookupProvider) + assertNotNull(result) + assertEquals("explicar", result!!.rootVerb) + assertEquals("infinitive", result.rootMood) + assertEquals(listOf("se", "lo"), result.separatedPronouns) + assertEquals("se lo va a explicar", result.procliticEquivalent) + assertEquals(3, result.blocks.size) // root, se, lo + assertEquals(PronounBlockType.ROOT_VERB, result.blocks[0].type) + assertEquals(PronounBlockType.REFLEXIVE_PRONOUN, result.blocks[1].type) + assertEquals(PronounBlockType.DIRECT_PRONOUN, result.blocks[2].type) + } + + @Test + fun performSurgery_gerundWithTwoClitics() { + val result = CliticSurgeryEngine.performSurgery("diciéndomelo", testLookupProvider) + assertNotNull(result) + assertEquals("diciendo", result!!.rootVerb) + assertEquals("gerund", result.rootMood) + assertEquals(listOf("me", "lo"), result.separatedPronouns) + assertEquals("me lo está diciendo", result.procliticEquivalent) + assertEquals(PronounBlockType.INDIRECT_PRONOUN, result.blocks[1].type) + } + + @Test + fun performSurgery_nonVerbReturnsNull() { + // "casa" is a noun, even if someone queried a word ending in clitic-like letters + val result = CliticSurgeryEngine.performSurgery("casas", testLookupProvider) + assertNull(result) + } +} diff --git a/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngineTest.kt b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngineTest.kt new file mode 100644 index 0000000..8e79842 --- /dev/null +++ b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/NumberOdometerEngineTest.kt @@ -0,0 +1,76 @@ +package com.anysoftkeyboard.calque.toolbox + +import org.junit.Assert.assertEquals +import org.junit.Test + +class NumberOdometerEngineTest { + + @Test + fun formatNumberWithAgreement_oneAndTwentyOne() { + val result1 = NumberOdometerEngine.formatNumberWithAgreement(1) + assertEquals("un", result1.masculineText) + assertEquals("una", result1.feminineText) + + val result21 = NumberOdometerEngine.formatNumberWithAgreement(21) + assertEquals("veintiún", result21.masculineText) + assertEquals("veintiuna", result21.feminineText) + } + + @Test + fun formatNumberWithAgreement_hundredsAgreement() { + val result200 = NumberOdometerEngine.formatNumberWithAgreement(200) + assertEquals("doscientos", result200.masculineText) + assertEquals("doscientas", result200.feminineText) + + val result501 = NumberOdometerEngine.formatNumberWithAgreement(501) + assertEquals("quinientos un", result501.masculineText) + assertEquals("quinientas una", result501.feminineText) + } + + @Test + fun formatClockTime_conventions() { + // Noon & Midnight + assertEquals("Es mediodía", NumberOdometerEngine.formatClockTime(12, 0).phrase) + assertEquals("Es medianoche", NumberOdometerEngine.formatClockTime(0, 0).phrase) + + // Exact hours + assertEquals( + "Es la una en punto de la madrugada", + NumberOdometerEngine.formatClockTime(1, 0).phrase + ) + assertEquals( + "Son las dos en punto de la madrugada", + NumberOdometerEngine.formatClockTime(2, 0).phrase + ) + + // Quarters and Half + assertEquals( + "Es la una y cuarto de la madrugada", + NumberOdometerEngine.formatClockTime(1, 15).phrase + ) + assertEquals( + "Son las tres y media de la tarde", + NumberOdometerEngine.formatClockTime(15, 30).phrase + ) + + // Less than quarter (next hour) + assertEquals( + "Son las cuatro menos cuarto de la tarde", + NumberOdometerEngine.formatClockTime(15, 45).phrase + ) + assertEquals( + "Son las cinco menos diez de la tarde", + NumberOdometerEngine.formatClockTime(16, 50).phrase + ) + } + + @Test + fun formatDate_formatsFirstAndOtherDays() { + assertEquals("el primero de enero", NumberOdometerEngine.formatDate(1, 1).phrase) + assertEquals("el 5 de mayo", NumberOdometerEngine.formatDate(5, 5).phrase) + assertEquals( + "el 12 de octubre de 1492", + NumberOdometerEngine.formatDate(12, 10, 1492).phrase + ) + } +} diff --git a/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngineTest.kt b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngineTest.kt new file mode 100644 index 0000000..799d8be --- /dev/null +++ b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/OmniBarEngineTest.kt @@ -0,0 +1,189 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.data.models.CognateRule +import com.anysoftkeyboard.calque.data.seed.CognateRulesRepository +import com.anysoftkeyboard.calque.data.seed.FalseCognatesRepository +import com.anysoftkeyboard.calque.linguistics.DictionaryEntry +import com.anysoftkeyboard.calque.linguistics.FalseCognateEntry +import com.anysoftkeyboard.calque.linguistics.UserWordStatus +import com.anysoftkeyboard.calque.linguistics.WordLookupProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class OmniBarEngineTest { + + private lateinit var omniBarEngine: OmniBarEngine + + @Before + fun setUp() { + omniBarEngine = + OmniBarEngine( + cognateRulesRepository = createTestRulesRepo(), + falseCognatesRepository = createTestFalseCognatesRepo(), + wordLookupProvider = createTestLookupProvider(), + ) + } + + @Test + fun route_digitsMatchesNumber() { + val result = omniBarEngine.route("492") + assertTrue(result is OmniBarResult.NumberResult) + val numResult = result as OmniBarResult.NumberResult + assertEquals(492L, numResult.number) + assertEquals("cuatrocientos noventa y dos", numResult.masculineWords) + } + + @Test + fun route_clockTimeMatchesTime() { + val result = omniBarEngine.route("14:30") + assertTrue(result is OmniBarResult.NumberResult) + val numResult = result as OmniBarResult.NumberResult + assertEquals("Son las dos y media de la tarde", numResult.masculineWords) + } + + @Test + fun route_suffixMatchesCognateRule() { + val result1 = omniBarEngine.route("-tion") + assertTrue(result1 is OmniBarResult.SuffixRuleResult) + assertEquals("rule_tion_cion", (result1 as OmniBarResult.SuffixRuleResult).rule.ruleId) + + val result2 = omniBarEngine.route("dad") + assertTrue(result2 is OmniBarResult.SuffixRuleResult) + assertEquals("rule_ty_dad", (result2 as OmniBarResult.SuffixRuleResult).rule.ruleId) + } + + @Test + fun route_falseCognateMatchesTrap() { + val result = omniBarEngine.route("carpeta") + assertTrue(result is OmniBarResult.FalseCognateResult) + val trapResult = result as OmniBarResult.FalseCognateResult + assertEquals("folder / binder", trapResult.card.actualEnglish) + } + + @Test + fun route_cliticMatchesSurgery() { + val result = omniBarEngine.route("explicárselo") + assertTrue(result is OmniBarResult.CliticResult) + val cliticResult = result as OmniBarResult.CliticResult + assertEquals("explicar", cliticResult.surgery.rootVerb) + assertEquals("se lo va a explicar", cliticResult.surgery.procliticEquivalent) + } + + @Test + fun route_dictionaryWordMatchesLookup() { + val result = omniBarEngine.route("libro") + assertTrue(result is OmniBarResult.WordLookupResult) + val wordResult = result as OmniBarResult.WordLookupResult + assertEquals("book", wordResult.word.englishGloss) + } + + @Test + fun route_emptyOrUnknownMatchesNone() { + assertTrue(omniBarEngine.route("") is OmniBarResult.None) + assertTrue(omniBarEngine.route("xyzrandomword") is OmniBarResult.None) + } + + private fun createTestRulesRepo(): CognateRulesRepository = + object : CognateRulesRepository { + private val rules = + listOf( + CognateRule( + ruleId = "rule_tion_cion", + englishSuffix = "-tion", + spanishSuffix = "-ción", + genderRule = "Always feminine (la)", + spellingNote = "Convert -tion to -ción.", + examplesJson = """[{"english":"action","spanish":"acción"}]""", + ), + CognateRule( + ruleId = "rule_ty_dad", + englishSuffix = "-ty", + spanishSuffix = "-dad", + genderRule = "Always feminine (la)", + spellingNote = "Convert -ty to -dad.", + examplesJson = """[{"english":"community","spanish":"comunidad"}]""", + ), + ) + + override fun getAll(): List = rules + + override fun getById(ruleId: String): CognateRule? = + rules.firstOrNull { it.ruleId == ruleId } + + override fun findRuleForSpanishWord(spanishWord: String): CognateRule? = + rules.firstOrNull { spanishWord.endsWith(it.spanishSuffix.removePrefix("-")) } + } + + private fun createTestFalseCognatesRepo(): FalseCognatesRepository = + object : FalseCognatesRepository { + private val traps = + mapOf( + "carpeta" to + FalseCognateEntry( + surfaceForm = "carpeta", + lemma = "carpeta", + apparentEnglish = "carpet", + actualEnglish = "folder / binder", + realSpanishForApparent = "alfombra", + severity = "HIGH_RISK", + disambiguationTip = "Carpeta is a file folder.", + exampleEs = "Pon el papel en la carpeta.", + exampleEn = "Put the paper in the folder.", + ), + ) + + override fun getBySurfaceForm(surfaceForm: String): FalseCognateEntry? = + traps[surfaceForm] + + override fun getBySurfaceForms( + surfaceForms: Collection + ): Map = + surfaceForms.mapNotNull { f -> traps[f]?.let { f to it } }.toMap() + + override fun getByLemma(lemma: String): List = + traps.values.filter { it.lemma == lemma } + + override fun getAll(): List = traps.values.toList() + } + + private fun createTestLookupProvider(): WordLookupProvider = + object : WordLookupProvider { + private val dictionary = + mapOf( + "explicar" to + DictionaryEntry( + surfaceForm = "explicar", + lemma = "explicar", + pos = "verb", + grammarInfo = "inf", + englishGloss = "to explain", + isCognate = true, + frequencyRank = 450, + ), + "libro" to + DictionaryEntry( + surfaceForm = "libro", + lemma = "libro", + pos = "noun", + grammarInfo = "masc", + englishGloss = "book", + isCognate = false, + frequencyRank = 120, + ), + ) + + override fun batchLookupDictionary(words: Set): Map = + words.mapNotNull { w -> dictionary[w]?.let { w to it } }.toMap() + + override fun batchLookupFalseCognates( + words: Set + ): Map = emptyMap() + + override fun batchLookupUserState(lemmas: Set): Map = + emptyMap() + + override fun lookupSingleWord(word: String): DictionaryEntry? = dictionary[word] + } +} diff --git a/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngineTest.kt b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngineTest.kt new file mode 100644 index 0000000..a8265ee --- /dev/null +++ b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TenseShifterEngineTest.kt @@ -0,0 +1,266 @@ +package com.anysoftkeyboard.calque.toolbox + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TenseShifterEngineTest { + + @Test + fun conjugateRegularAr_present() { + assertEquals( + "hablo", + TenseShifterEngine.conjugate("hablar", SubjectPronoun.YO, ReadingTense.PRESENT).fullForm + ) + assertEquals( + "hablas", + TenseShifterEngine.conjugate("hablar", SubjectPronoun.TU, ReadingTense.PRESENT).fullForm + ) + assertEquals( + "habla", + TenseShifterEngine.conjugate( + "hablar", + SubjectPronoun.EL_ELLA_USTED, + ReadingTense.PRESENT + ) + .fullForm + ) + assertEquals( + "hablamos", + TenseShifterEngine.conjugate("hablar", SubjectPronoun.NOSOTROS, ReadingTense.PRESENT) + .fullForm + ) + assertEquals( + "hablan", + TenseShifterEngine.conjugate( + "hablar", + SubjectPronoun.ELLOS_USTEDES, + ReadingTense.PRESENT + ) + .fullForm + ) + } + + @Test + fun conjugateRegularAr_pastAndFuture() { + // Preterite + assertEquals( + "hablé", + TenseShifterEngine.conjugate("hablar", SubjectPronoun.YO, ReadingTense.PAST_COMPLETED) + .fullForm + ) + assertEquals( + "habló", + TenseShifterEngine.conjugate( + "hablar", + SubjectPronoun.EL_ELLA_USTED, + ReadingTense.PAST_COMPLETED + ) + .fullForm + ) + + // Imperfect + assertEquals( + "hablaba", + TenseShifterEngine.conjugate("hablar", SubjectPronoun.YO, ReadingTense.PAST_ONGOING) + .fullForm + ) + assertEquals( + "hablábamos", + TenseShifterEngine.conjugate( + "hablar", + SubjectPronoun.NOSOTROS, + ReadingTense.PAST_ONGOING + ) + .fullForm + ) + + // Near Future + assertEquals( + "voy a hablar", + TenseShifterEngine.conjugate("hablar", SubjectPronoun.YO, ReadingTense.NEAR_FUTURE) + .fullForm + ) + assertEquals( + "vamos a hablar", + TenseShifterEngine.conjugate( + "hablar", + SubjectPronoun.NOSOTROS, + ReadingTense.NEAR_FUTURE + ) + .fullForm + ) + } + + @Test + fun conjugateRegularEr_comer() { + assertEquals( + "como", + TenseShifterEngine.conjugate("comer", SubjectPronoun.YO, ReadingTense.PRESENT).fullForm + ) + assertEquals( + "comió", + TenseShifterEngine.conjugate( + "comer", + SubjectPronoun.EL_ELLA_USTED, + ReadingTense.PAST_COMPLETED + ) + .fullForm + ) + assertEquals( + "comía", + TenseShifterEngine.conjugate("comer", SubjectPronoun.YO, ReadingTense.PAST_ONGOING) + .fullForm + ) + assertEquals( + "va a comer", + TenseShifterEngine.conjugate( + "comer", + SubjectPronoun.EL_ELLA_USTED, + ReadingTense.NEAR_FUTURE + ) + .fullForm + ) + } + + @Test + fun conjugateRegularIr_vivir() { + assertEquals( + "vivo", + TenseShifterEngine.conjugate("vivir", SubjectPronoun.YO, ReadingTense.PRESENT).fullForm + ) + assertEquals( + "vivieron", + TenseShifterEngine.conjugate( + "vivir", + SubjectPronoun.ELLOS_USTEDES, + ReadingTense.PAST_COMPLETED + ) + .fullForm + ) + assertEquals( + "vivíamos", + TenseShifterEngine.conjugate( + "vivir", + SubjectPronoun.NOSOTROS, + ReadingTense.PAST_ONGOING + ) + .fullForm + ) + assertEquals( + "van a vivir", + TenseShifterEngine.conjugate( + "vivir", + SubjectPronoun.ELLOS_USTEDES, + ReadingTense.NEAR_FUTURE + ) + .fullForm + ) + } + + @Test + fun conjugateIrregular_serAndEstar() { + // ser + assertEquals( + "soy", + TenseShifterEngine.conjugate("ser", SubjectPronoun.YO, ReadingTense.PRESENT).fullForm + ) + assertEquals( + "fui", + TenseShifterEngine.conjugate("ser", SubjectPronoun.YO, ReadingTense.PAST_COMPLETED) + .fullForm + ) + assertEquals( + "era", + TenseShifterEngine.conjugate("ser", SubjectPronoun.YO, ReadingTense.PAST_ONGOING) + .fullForm + ) + + // estar + assertEquals( + "estoy", + TenseShifterEngine.conjugate("estar", SubjectPronoun.YO, ReadingTense.PRESENT).fullForm + ) + assertEquals( + "estuve", + TenseShifterEngine.conjugate("estar", SubjectPronoun.YO, ReadingTense.PAST_COMPLETED) + .fullForm + ) + assertEquals( + "estaba", + TenseShifterEngine.conjugate("estar", SubjectPronoun.YO, ReadingTense.PAST_ONGOING) + .fullForm + ) + } + + @Test + fun conjugateIrregular_irAndTener() { + // ir + assertEquals( + "voy", + TenseShifterEngine.conjugate("ir", SubjectPronoun.YO, ReadingTense.PRESENT).fullForm + ) + assertEquals( + "fueron", + TenseShifterEngine.conjugate( + "ir", + SubjectPronoun.ELLOS_USTEDES, + ReadingTense.PAST_COMPLETED + ) + .fullForm + ) + assertEquals( + "iba", + TenseShifterEngine.conjugate("ir", SubjectPronoun.YO, ReadingTense.PAST_ONGOING) + .fullForm + ) + + // tener + assertEquals( + "tengo", + TenseShifterEngine.conjugate("tener", SubjectPronoun.YO, ReadingTense.PRESENT).fullForm + ) + assertEquals( + "tuvo", + TenseShifterEngine.conjugate( + "tener", + SubjectPronoun.EL_ELLA_USTED, + ReadingTense.PAST_COMPLETED + ) + .fullForm + ) + assertEquals( + "tenían", + TenseShifterEngine.conjugate( + "tener", + SubjectPronoun.ELLOS_USTEDES, + ReadingTense.PAST_ONGOING + ) + .fullForm + ) + } + + @Test + fun shiftSentence_replacesVerbCorrectly() { + val result = + TenseShifterEngine.shiftSentence( + sentence = "Yo hablo español.", + lemma = "hablar", + pronoun = SubjectPronoun.YO, + tense = ReadingTense.PAST_COMPLETED, + ) + + assertEquals("hablé", result.newVerb) + assertEquals("Yo hablé español.", result.shiftedSentence) + } + + @Test + fun supportedVerbs_includesCommonIrregulars() { + val verbs = TenseShifterEngine.supportedVerbs() + assertTrue(verbs.contains("hablar")) + assertTrue(verbs.contains("ser")) + assertTrue(verbs.contains("estar")) + assertTrue(verbs.contains("tener")) + assertTrue(verbs.contains("hacer")) + } +} diff --git a/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngineTest.kt b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngineTest.kt new file mode 100644 index 0000000..5a1e3d8 --- /dev/null +++ b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/TrapDetectorEngineTest.kt @@ -0,0 +1,98 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.linguistics.FalseCognateEntry +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TrapDetectorEngineTest { + + private val sampleTraps = + listOf( + FalseCognateEntry( + surfaceForm = "éxito", + lemma = "éxito", + apparentEnglish = "exit", + actualEnglish = "success", + realSpanishForApparent = "salida", + severity = "HIGH_RISK", + disambiguationTip = "Éxito means success. For an exit door, look for 'salida'.", + exampleEs = "La película fue un gran éxito.", + exampleEn = "The movie was a great success.", + ), + FalseCognateEntry( + surfaceForm = "carpeta", + lemma = "carpeta", + apparentEnglish = "carpet", + actualEnglish = "folder / binder", + realSpanishForApparent = "alfombra", + severity = "HIGH_RISK", + disambiguationTip = + "Carpeta is a file folder or binder. For floor carpet, use 'alfombra'.", + exampleEs = "Pon los documentos en la carpeta azul.", + exampleEn = "Put the documents in the blue folder.", + ), + FalseCognateEntry( + surfaceForm = "sensible", + lemma = "sensible", + apparentEnglish = "sensible", + actualEnglish = "sensitive", + realSpanishForApparent = "sensato / razonable", + severity = "MILD_NUANCE", + disambiguationTip = + "Sensible means sensitive. For someone sensible/prudent, use 'sensato'.", + exampleEs = "Ella tiene la piel muy sensible.", + exampleEn = "She has very sensitive skin.", + ), + ) + + @Test + fun filterTraps_allReturnsEverything() { + val result = TrapDetectorEngine.filterTraps(sampleTraps, TrapFilter.ALL) + assertEquals(3, result.size) + } + + @Test + fun filterTraps_highRiskFiltersCorrectly() { + val result = TrapDetectorEngine.filterTraps(sampleTraps, TrapFilter.HIGH_RISK) + assertEquals(2, result.size) + assertTrue(result.all { it.severity == "HIGH_RISK" }) + } + + @Test + fun filterTraps_mildNuanceFiltersCorrectly() { + val result = TrapDetectorEngine.filterTraps(sampleTraps, TrapFilter.MILD_NUANCE) + assertEquals(1, result.size) + assertEquals("sensible", result.first().lemma) + } + + @Test + fun filterTraps_seenInReadingMatchesSeenLemmas() { + val seen = setOf("carpeta") + val result = TrapDetectorEngine.filterTraps(sampleTraps, TrapFilter.SEEN_IN_READING, seen) + assertEquals(1, result.size) + assertEquals("carpeta", result.first().lemma) + } + + @Test + fun createQuizQuestion_generatesValidQuestion() { + val trap = sampleTraps.first() // éxito + val question = TrapDetectorEngine.createQuizQuestion(trap, correctFirst = true) + + assertEquals("La película fue un gran éxito.", question.sentenceEs) + assertEquals("éxito", question.targetWord) + assertEquals(0, question.correctOptionIndex) + assertEquals("success", question.options[0]) + assertEquals("exit", question.options[1]) + assertTrue(question.explanation.contains("salida")) + } + + @Test + fun generateQuiz_alternatesOptionOrder() { + val quiz = TrapDetectorEngine.generateQuiz(sampleTraps, alternateAnswers = true) + assertEquals(3, quiz.size) + assertEquals(0, quiz[0].correctOptionIndex) + assertEquals(1, quiz[1].correctOptionIndex) + assertEquals(0, quiz[2].correctOptionIndex) + } +} diff --git a/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngineTest.kt b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngineTest.kt new file mode 100644 index 0000000..f5ca39f --- /dev/null +++ b/core/toolbox/src/test/kotlin/com/anysoftkeyboard/calque/toolbox/WordTransformerEngineTest.kt @@ -0,0 +1,96 @@ +package com.anysoftkeyboard.calque.toolbox + +import com.anysoftkeyboard.calque.data.models.CognateRule +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +class WordTransformerEngineTest { + + private val sampleRules = + listOf( + CognateRule( + ruleId = "rule_tion_cion", + englishSuffix = "-tion", + spanishSuffix = "-ción", + genderRule = "Always feminine (la)", + spellingNote = + "Convert -tion to -ción. Double consonants simplify: attention -> atención.", + examplesJson = """[{"english":"action","spanish":"acción"}]""", + ), + CognateRule( + ruleId = "rule_ty_dad", + englishSuffix = "-ty", + spanishSuffix = "-dad", + genderRule = "Always feminine (la)", + spellingNote = "Convert -ty to -dad. Difficult consonants simplify.", + examplesJson = """[{"english":"community","spanish":"comunidad"}]""", + ), + CognateRule( + ruleId = "rule_ous_oso", + englishSuffix = "-ous", + spanishSuffix = "-oso", + genderRule = "Masc: -oso, Fem: -osa", + spellingNote = "Convert -ous to -oso/-osa.", + examplesJson = """[{"english":"famous","spanish":"famoso"}]""", + ), + CognateRule( + ruleId = "rule_ly_mente", + englishSuffix = "-ly", + spanishSuffix = "-mente", + genderRule = null, + spellingNote = "Convert -ly to -mente.", + examplesJson = """[{"english":"rapidly","spanish":"rápidamente"}]""", + ), + ) + + @Test + fun transformEnglishToSpanish_tionToCion() { + val result = WordTransformerEngine.transformEnglishToSpanish("situation", sampleRules) + assertNotNull(result) + assertEquals("situación", result!!.resultWord) + assertEquals("Always feminine (la)", result.genderRule) + } + + @Test + fun transformEnglishToSpanish_simplifiesDoubleConsonants() { + val result = WordTransformerEngine.transformEnglishToSpanish("attention", sampleRules) + assertNotNull(result) + assertEquals("atención", result!!.resultWord) + } + + @Test + fun transformEnglishToSpanish_tyToDad() { + val result = WordTransformerEngine.transformEnglishToSpanish("community", sampleRules) + assertNotNull(result) + assertEquals("comunidad", result!!.resultWord) + } + + @Test + fun transformEnglishToSpanish_ousToOso() { + val result = WordTransformerEngine.transformEnglishToSpanish("famous", sampleRules) + assertNotNull(result) + assertEquals("famoso", result!!.resultWord) + } + + @Test + fun transformSpanishToEnglish_cionToTion() { + val result = WordTransformerEngine.transformSpanishToEnglish("comunicación", sampleRules) + assertNotNull(result) + assertEquals("comunication", result!!.resultWord) + } + + @Test + fun transformSpanishToEnglish_dadToTy() { + val result = WordTransformerEngine.transformSpanishToEnglish("universidad", sampleRules) + assertNotNull(result) + assertEquals("university", result!!.resultWord) + } + + @Test + fun transformEnglishToSpanish_unknownSuffixReturnsNull() { + val result = WordTransformerEngine.transformEnglishToSpanish("running", sampleRules) + assertNull(result) + } +}