From 1b29fc3b3ba72cb8653c3f7f96ae5262c918b87a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= Date: Mon, 12 Jan 2026 11:39:30 +0000 Subject: [PATCH 1/4] test: add Vietnamese Telex input test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created comprehensive test suite for Vietnamese Telex transformations - Added JUnit dependency to build.gradle.kts for testing support - Tests cover: basic transformations, case sensitivity, multi-char patterns, edge cases - 13 tests total: 10 passing, 3 failing (documenting known bugs) - Failing tests identify bugs in the transformation logic: * 'ow' → 'ơ' pattern not working * Case-insensitive 'W' handling broken * Multi-character 'ww' patterns need fixing - Tests will pass once transformation logic is corrected --- app/build.gradle.kts | 3 + .../keyboard/services/VietnameseTelexTest.kt | 205 ++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt 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/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt b/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt new file mode 100644 index 00000000..b4a30ed1 --- /dev/null +++ b/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt @@ -0,0 +1,205 @@ +package org.fossify.keyboard.services + +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test + +class VietnameseTelexTest { + + private lateinit var telexRules: HashMap + + @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) + + val longestFirstResult = applyRulesLongestFirst(input) + assertEquals("ư", 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) + assertEquals("wư", result) + } + + @Test + fun testSpaceDoubleW() { + val input = "O ww" + val result = applyRulesLongestFirst(input) + assertEquals("O ư", 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() + + if (telexRules.containsKey(shouldChangeText)) { + val prefix = word.substring(0, word.length - shouldChangeText.length) + return prefix + telexRules[shouldChangeText] + } + + 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 { + 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 + } + + /** + * 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 + } + + /** + * Helper function that applies rules with case-insensitive matching. + * Matches rules regardless of input case and preserves case in output. + */ + private fun applyRulesCaseInsensitive(word: String): String { + 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 transformed = telexRules[suffixLower]!! + + val result = if (suffix[0].isUpperCase() && transformed.isNotEmpty()) { + transformed[0].uppercaseChar() + transformed.substring(1) + } else { + transformed + } + + return prefix + result + } + } + + return word + } +} \ No newline at end of file From 86e80bae845af9a96644bb5010b745cefb6b8fd3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= Date: Mon, 12 Jan 2026 14:32:00 +0000 Subject: [PATCH 2/4] fix: resolve detekt issues in VietnameseTelexTest - Remove unused applyRulesCaseInsensitive function - Add newline at end of file --- .../keyboard/services/VietnameseTelexTest.kt | 27 +------------------ 1 file changed, 1 insertion(+), 26 deletions(-) diff --git a/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt b/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt index b4a30ed1..54adb36f 100644 --- a/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt +++ b/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt @@ -177,29 +177,4 @@ class VietnameseTelexTest { return word } - /** - * Helper function that applies rules with case-insensitive matching. - * Matches rules regardless of input case and preserves case in output. - */ - private fun applyRulesCaseInsensitive(word: String): String { - 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 transformed = telexRules[suffixLower]!! - - val result = if (suffix[0].isUpperCase() && transformed.isNotEmpty()) { - transformed[0].uppercaseChar() + transformed.substring(1) - } else { - transformed - } - - return prefix + result - } - } - - return word - } -} \ No newline at end of file +} From 77c843f1399f83e8c0aacb9828ca8f48b331e68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=E1=BA=A3=20th=E1=BA=BF=20gi=E1=BB=9Bi=20l=C3=A0=20Rust?= Date: Mon, 12 Jan 2026 15:17:35 +0000 Subject: [PATCH 3/4] feat: fix Vietnamese Telex input transformation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change iteration from backwards to forward (longest-match-first) - Add case-insensitive matching for Telex rules - Add case preservation (Aw→Ă, aw→ă, Ow→Ơ, ow→ơ) - Add Vietnamese chars (ă, ư, ơ) to keyboard popups - Fix test helpers to use case-insensitive matching - Add missing 'ww→ư' rule to test suite - Add 4 new tests for case preservation verification --- .../keyboard/services/SimpleKeyboardIME.kt | 21 +++--- .../res/xml/keys_letters_english_qwerty.xml | 6 +- .../keyboard/services/VietnameseTelexTest.kt | 64 +++++++++++++++++-- 3 files changed, 75 insertions(+), 16 deletions(-) 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..df910206 100644 --- a/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt +++ b/app/src/main/kotlin/org/fossify/keyboard/services/SimpleKeyboardIME.kt @@ -383,14 +383,19 @@ 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) + 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 } 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" /> Date: Tue, 13 Jan 2026 11:10:25 +0000 Subject: [PATCH 4/4] feat: add Vietnamese Telex escape sequences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implement escape logic for double character sequences (ww → w) - Add escape detection before transformation in SimpleKeyboardIME.kt - Fix test assertion argument order for JUnit 4 compatibility All 19 Vietnamese Telex tests passing. --- .../keyboard/services/SimpleKeyboardIME.kt | 31 +++++++++- .../keyboard/services/VietnameseTelexTest.kt | 58 +++++++++++++++++-- 2 files changed, 83 insertions(+), 6 deletions(-) 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 df910206..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,23 +383,50 @@ class SimpleKeyboardIME : InputMethodService(), OnKeyboardActionListener, Shared } else 0 if (lastIndexEmpty >= 0) { val word = fullText.subSequence(lastIndexEmpty, fullText.length).trim().toString() + + // 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()) { + val finalReplacement = if ( + partialWord.firstOrNull()?.isUpperCase() == true && + replacement.isNotEmpty() + ) { replacement.replaceFirstChar { it.uppercase() } } else { replacement } - inputConnection.setComposingRegion(fullText.length - partialWordLower.length, fullText.length) + 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/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt b/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt index 166b472e..362d23cc 100644 --- a/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt +++ b/app/src/test/kotlin/org/fossify/keyboard/services/VietnameseTelexTest.kt @@ -12,7 +12,6 @@ class VietnameseTelexTest { fun setup() { telexRules = hashMapOf( "w" to "ư", - "ww" to "ư", "a" to "ă", "aw" to "ă", "aa" to "â", @@ -66,8 +65,9 @@ class VietnameseTelexTest { val shortestFirstResult = applyRulesShortestFirst(input) assertEquals("wư", shortestFirstResult) + // With escape logic, "ww" should produce "w" (escape sequence) val longestFirstResult = applyRulesLongestFirst(input) - assertEquals("ư", longestFirstResult) + assertEquals("w", longestFirstResult) } @Test @@ -113,14 +113,16 @@ class VietnameseTelexTest { fun testTripleW() { val input = "www" val result = applyRulesLongestFirst(input) - assertEquals("wư", result) + // "www" → lastTwo "ww" escape to "w", result is "ww" + assertEquals("ww", result) } @Test fun testSpaceDoubleW() { val input = "O ww" val result = applyRulesLongestFirst(input) - assertEquals("O ư", result) + // "ww" after space escapes to "w" + assertEquals("O w", result) } @Test @@ -151,6 +153,21 @@ class VietnameseTelexTest { 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. @@ -180,6 +197,23 @@ class VietnameseTelexTest { * 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() @@ -197,6 +231,21 @@ class VietnameseTelexTest { * 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() @@ -212,6 +261,7 @@ class VietnameseTelexTest { return prefix + finalReplacement } } + return word }