diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c7ed8ad2..11715ad7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -146,4 +146,7 @@ dependencies { implementation(libs.bundles.room) ksp(libs.androidx.room.compiler) detektPlugins(libs.compose.detekt) + + // Unit testing dependencies + testImplementation("junit:junit:4.13.2") } diff --git a/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt b/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt index 45b1cbdb..7ac46c46 100644 --- a/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt +++ b/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt @@ -383,18 +383,50 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared } else 0 if (lastIndexEmpty >= 0) { val word = fullText.subSequence(lastIndexEmpty, fullText.length).trim().toString() - val wordChars = word.toCharArray() - val predictWord = StringBuilder() - for (char in wordChars.size - 1 downTo 0) { - predictWord.append(wordChars[char]) - val shouldChangeText = predictWord.reverse().toString() - if (cachedVNTelexData.containsKey(shouldChangeText)) { - inputConnection.setComposingRegion(fullText.length - shouldChangeText.length, fullText.length) - inputConnection.setComposingText(cachedVNTelexData[shouldChangeText], fullText.length) + + // Check for escape sequence FIRST (before single-char transformations) + // Only applies when: 1) last two chars are same, + // 2) no rule for doubled sequence, 3) rule exists for single char + if (word.length >= 2) { + val lastTwo = word.takeLast(2) + if (lastTwo[0] == lastTwo[1]) { + val doubledSeq = lastTwo.lowercase() + val singleChar = lastTwo[0].toString().lowercase() + // If there's NO rule for the doubled sequence, + // but there IS a rule for single char, it's an escape + if (!cachedVNTelexData.containsKey(doubledSeq) && cachedVNTelexData.containsKey(singleChar)) { + // This is an escape sequence - delete last char to keep just one + inputConnection.deleteSurroundingText(1, 0) + return + } + } + } + + // Then check for transformation rules (longest patterns first) + for (i in word.indices) { + val partialWord = word.substring(i, word.length) + val partialWordLower = partialWord.lowercase() + if (cachedVNTelexData.containsKey(partialWordLower)) { + val replacement = cachedVNTelexData[partialWordLower]!! + // Preserve case: if first char is uppercase, capitalize replacement + val finalReplacement = if ( + partialWord.firstOrNull()?.isUpperCase() == true && + replacement.isNotEmpty() + ) { + replacement.replaceFirstChar { it.uppercase() } + } else { + replacement + } + inputConnection.setComposingRegion( + fullText.length - partialWordLower.length, + fullText.length + ) + inputConnection.setComposingText(finalReplacement, fullText.length) inputConnection.setComposingRegion(fullText.length, fullText.length) return } } + inputConnection.commitText(codeChar.toString(), 1) updateShiftKeyState() } diff --git a/app/src/main/res/xml/keys_letters_english_qwerty.xml b/app/src/main/res/xml/keys_letters_english_qwerty.xml index e6fdf53d..2690dd16 100644 --- a/app/src/main/res/xml/keys_letters_english_qwerty.xml +++ b/app/src/main/res/xml/keys_letters_english_qwerty.xml @@ -43,7 +43,7 @@ app:topSmallNumber="1" /> + + @Before + fun setup() { + telexRules = hashMapOf( + "w" to "ư", + "a" to "ă", + "aw" to "ă", + "aa" to "â", + "dd" to "đ", + "ee" to "ê", + "oo" to "ô", + "ow" to "ơ", + "uw" to "ư", + "uow" to "ươ" + ) + } + + @Test + fun testShortestMatchFirst() { + val input = "aw" + val result = applyRulesShortestFirst(input) + assertEquals("aư", result) + } + + @Test + fun testLongestMatchFirst() { + val input = "aw" + val result = applyRulesLongestFirst(input) + assertEquals("ă", result) + } + + @Test + fun testDoubleA() { + val input = "aa" + val result = applyRulesLongestFirst(input) + assertEquals("â", result) + } + + @Test + fun testTripleCharacter() { + val input = "uow" + val result = applyRulesLongestFirst(input) + assertEquals("ươ", result) + } + + @Test + fun testNoTransformation() { + val input = "b" + val result = applyRulesLongestFirst(input) + assertEquals("b", result) + } + + @Test + fun testDoubleWTransformation() { + val input = "ww" + val shortestFirstResult = applyRulesShortestFirst(input) + assertEquals("wư", shortestFirstResult) + + // With escape logic, "ww" should produce "w" (escape sequence) + val longestFirstResult = applyRulesLongestFirst(input) + assertEquals("w", longestFirstResult) + } + + @Test + fun testDoubleDTransformation() { + val input = "dd" + val result = applyRulesLongestFirst(input) + assertEquals("đ", result) + } + + @Test + fun testMixedCaseNotMatching() { + val input = "Ee" + val result = applyRulesCaseSensitive(input) + assertEquals("Ee", result) + } + + @Test + fun testAllUppercaseNotMatching() { + val input = "EE" + val result = applyRulesCaseSensitive(input) + assertEquals("EE", result) + } + + @Test + fun testPatternPrecedence() { + val input = "aw" + + val shortestFirstResult = applyRulesShortestFirst(input) + assertEquals("aư", shortestFirstResult) + + val longestFirstResult = applyRulesLongestFirst(input) + assertEquals("ă", longestFirstResult) + } + + @Test + fun testSingleW() { + val input = "w" + val result = applyRulesLongestFirst(input) + assertEquals("ư", result) + } + + @Test + fun testTripleW() { + val input = "www" + val result = applyRulesLongestFirst(input) + // "www" → lastTwo "ww" escape to "w", result is "ww" + assertEquals("ww", result) + } + + @Test + fun testSpaceDoubleW() { + val input = "O ww" + val result = applyRulesLongestFirst(input) + // "ww" after space escapes to "w" + assertEquals("O w", result) + } + + @Test + fun testUppercaseCasePreservation_Aw() { + val input = "Aw" + val result = applyRulesWithCasePreservation(input) + assertEquals("Ă", result) + } + + @Test + fun testLowercaseCasePreservation_aw() { + val input = "aw" + val result = applyRulesWithCasePreservation(input) + assertEquals("ă", result) + } + + @Test + fun testUppercaseCasePreservation_Ow() { + val input = "Ow" + val result = applyRulesWithCasePreservation(input) + assertEquals("Ơ", result) + } + + @Test + fun testLowercaseCasePreservation_ow() { + val input = "ow" + val result = applyRulesWithCasePreservation(input) + assertEquals("ơ", result) + } + + @Test + fun testEscapeSequence_ww() { + // Typing "ww" should produce "w" (escape transformation) + val result = applyRulesLongestFirst("ww") + assertEquals("Double 'w' should escape to literal 'w'", "w", result) + } + + @Test + fun testEscapeAfterTransformation() { + // "ưw" is not a doubled character (last two are 'ư' and 'w'), so "w" transforms normally to "ư" + // This gives us "ư" (prefix) + "ư" (transformation of "w") = "ưư" + val result = applyRulesLongestFirst("ưw") + assertEquals("ưw should transform the 'w' to 'ư', giving 'ưư'", "ưư", result) + } + + /** + * Helper function that applies transformation rules checking shortest patterns first. + * This demonstrates incorrect behavior when shorter patterns match before longer ones. + */ + private fun applyRulesShortestFirst(word: String): String { + val wordChars = word.toCharArray() + val predictWord = StringBuilder() + + for (char in wordChars.size - 1 downTo 0) { + predictWord.append(wordChars[char]) + val shouldChangeText = predictWord.reverse().toString() + val shouldChangeTextLower = shouldChangeText.lowercase() + + if (telexRules.containsKey(shouldChangeTextLower)) { + val prefix = word.substring(0, word.length - shouldChangeText.length) + return prefix + telexRules[shouldChangeTextLower] + } + + predictWord.reverse() + } + + return word + } + + /** + * Helper function that applies transformation rules checking longest patterns first. + * This demonstrates correct behavior where longer patterns take precedence. + */ + private fun applyRulesLongestFirst(word: String): String { + // Check for escape sequence FIRST (before single-char transformations) + // Only applies when: 1) last two chars are same, + // 2) no rule for doubled sequence, 3) rule exists for single char + if (word.length >= 2) { + val lastTwo = word.takeLast(2) + if (lastTwo[0] == lastTwo[1]) { + val doubledSeq = lastTwo.lowercase() + val singleChar = lastTwo[0].toString().lowercase() + // If there's NO rule for the doubled sequence, but there IS a rule for single char, it's an escape + if (!telexRules.containsKey(doubledSeq) && telexRules.containsKey(singleChar)) { + // This is an escape sequence - return word with just one of the doubled char + return word.substring(0, word.length - 1) + } + } + } + + // Then check for transformation rules (longest patterns first) + for (length in word.length downTo 1) { + val suffix = word.substring(word.length - length) + val suffixLower = suffix.lowercase() + if (telexRules.containsKey(suffixLower)) { + val prefix = word.substring(0, word.length - length) + return prefix + telexRules[suffixLower]!! + } + } + + return word + } + + /** + * Helper function that applies transformation rules with case preservation. + * Matches case-insensitively but preserves the original case in output. + */ + private fun applyRulesWithCasePreservation(word: String): String { + // Check for escape sequence FIRST (before single-char transformations) + if (word.length >= 2) { + val lastTwo = word.takeLast(2) + if (lastTwo[0] == lastTwo[1]) { + val doubledSeq = lastTwo.lowercase() + val singleChar = lastTwo[0].toString().lowercase() + // If there's NO rule for the doubled sequence, but there IS a rule for single char, it's an escape + if (!telexRules.containsKey(doubledSeq) && telexRules.containsKey(singleChar)) { + // This is an escape sequence - return word with just one of the doubled char + return word.substring(0, word.length - 1) + } + } + } + + // Then check for transformation rules (longest patterns first) + for (length in word.length downTo 1) { + val suffix = word.substring(word.length - length) + val suffixLower = suffix.lowercase() + if (telexRules.containsKey(suffixLower)) { + val prefix = word.substring(0, word.length - length) + val replacement = telexRules[suffixLower]!! + // Preserve case: if first char is uppercase, capitalize replacement + val finalReplacement = if (suffix.firstOrNull()?.isUpperCase() == true && replacement.isNotEmpty()) { + replacement.replaceFirstChar { it.uppercase() } + } else { + replacement + } + return prefix + finalReplacement + } + } + + return word + } + + /** + * Helper function that applies rules with case-sensitive matching. + * Mixed-case input won't match lowercase rules. + */ + private fun applyRulesCaseSensitive(word: String): String { + for (length in word.length downTo 1) { + val suffix = word.substring(word.length - length) + if (telexRules.containsKey(suffix)) { + val prefix = word.substring(0, word.length - length) + return prefix + telexRules[suffix]!! + } + } + + return word + } + +}