diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 69f8b958c..22356668a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -287,6 +287,7 @@ dependencies { // Unit Testing // ========================== testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version") + testImplementation("org.junit.jupiter:junit-jupiter-params:${junit5Version}") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit5Version") testRuntimeOnly("org.junit.vintage:junit-vintage-engine:$junit5Version") testImplementation("io.mockk:mockk:$mockkVersion") diff --git a/app/src/keyboards/java/be/scri/helpers/AutocompletionHandler.kt b/app/src/keyboards/java/be/scri/helpers/AutocompletionHandler.kt index f1f9f4142..598cfdf30 100644 --- a/app/src/keyboards/java/be/scri/helpers/AutocompletionHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/AutocompletionHandler.kt @@ -10,11 +10,12 @@ import be.scri.models.ScribeState * Handles autocompletion when user is typing. * * @property ime The [KeyboardIMEContext] instance this handler is associated with. + * @property handler The [Handler] used to schedule autocompletion tasks. */ class AutocompletionHandler( private val ime: KeyboardIMEContext, + private val handler: Handler = Handler(Looper.getMainLooper()), ) { - private val handler = Handler(Looper.getMainLooper()) private var autocompleteRunnable: Runnable? = null companion object { @@ -62,9 +63,7 @@ class AutocompletionHandler( val completions = ime.getAutocompletions(currentWord, previousWord, limit = MAX_COMPLETIONS + 1) ime.updateAutocompleteCompletions(buildCompletions(currentWord, completions)) - } - - handler.postDelayed(autocompleteRunnable!!, AUTOCOMPLETE_DELAY_MS) + }.also { handler.postDelayed(it, AUTOCOMPLETE_DELAY_MS) } } /** diff --git a/app/src/keyboards/java/be/scri/helpers/RecentEmojiHelper.kt b/app/src/keyboards/java/be/scri/helpers/RecentEmojiHelper.kt index ad0dc3e78..029e3fd53 100644 --- a/app/src/keyboards/java/be/scri/helpers/RecentEmojiHelper.kt +++ b/app/src/keyboards/java/be/scri/helpers/RecentEmojiHelper.kt @@ -12,19 +12,25 @@ fun recordRecentEmoji( emoji: String, ) { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - val current = - prefs - .getString(KEY_RECENT, "")!! - .split(",") - .filter { it.isNotBlank() } - .toMutableList() - current.remove(emoji) - current.add(0, emoji) - while (current.size > MAX_RECENT) current.removeAt(current.lastIndex) - prefs.edit().putString(KEY_RECENT, current.joinToString(",")).apply() + + val recentEmojisPrefs = prefs.getString(KEY_RECENT, "") + + recentEmojisPrefs?.let { recentEmojis -> + val current = + recentEmojis + .split(",") + .filter { it.isNotBlank() } + .toMutableList() + + current.remove(emoji) + current.add(0, emoji) + while (current.size > MAX_RECENT) current.removeAt(current.lastIndex) + prefs.edit().putString(KEY_RECENT, current.joinToString(",")).apply() + } } fun getRecentEmojis(context: Context): List { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getString(KEY_RECENT, "")!!.split(",").filter { it.isNotBlank() } + val recentEmojisPrefs = prefs.getString(KEY_RECENT, "") ?: "" + return recentEmojisPrefs.split(",").filter { it.isNotBlank() } } diff --git a/app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt b/app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt index 5210e371f..30189da1a 100644 --- a/app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt +++ b/app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt @@ -320,7 +320,7 @@ object HintUtils { ScribeState.TRANSLATE -> getTranslationPrompt(language, context) ScribeState.CONJUGATE -> getConjugationPrompt(language) ScribeState.PLURAL -> getPluralPrompt(language) - ScribeState.SELECT_VERB_CONJUNCTION -> text!! + ScribeState.SELECT_VERB_CONJUNCTION -> text ?: "" else -> "" } diff --git a/app/src/main/java/be/scri/helpers/EmojiHelper.kt b/app/src/main/java/be/scri/helpers/EmojiHelper.kt index fca1b156b..44487ed5b 100644 --- a/app/src/main/java/be/scri/helpers/EmojiHelper.kt +++ b/app/src/main/java/be/scri/helpers/EmojiHelper.kt @@ -20,16 +20,18 @@ fun parseRawEmojiSpecsFile( path: String, ): MutableList { val emojis = mutableListOf() - var emojiEditorList: MutableList? = null + var emojiEditorList = mutableListOf() var category: String? = null fun commitEmojiEditorList() { - emojiEditorList?.let { - val base = it.first() - val variants = it.drop(1) - emojis.add(EmojiData(category ?: "none", base, variants)) + if (emojiEditorList.isNotEmpty()) { + emojiEditorList.let { + val base = it.first() + val variants = it.drop(1) + emojis.add(EmojiData(category ?: "none", base, variants)) + } } - emojiEditorList = null + emojiEditorList = emptyList().toMutableList() } context.assets.open(path).bufferedReader().useLines { lines -> @@ -48,8 +50,8 @@ fun parseRawEmojiSpecsFile( val data = line.split(";") if (data.size == 3) { val emoji = data[0].trim() - if (emojiEditorList != null) { - emojiEditorList!!.add(emoji) + if (emojiEditorList.isNotEmpty()) { + emojiEditorList.add(emoji) } else { emojiEditorList = mutableListOf(emoji) } diff --git a/app/src/test/kotlin/be/scri/helpers/EmojiHelperTest.kt b/app/src/test/kotlin/be/scri/helpers/EmojiHelperTest.kt new file mode 100644 index 000000000..35e69099c --- /dev/null +++ b/app/src/test/kotlin/be/scri/helpers/EmojiHelperTest.kt @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers + +import android.content.Context +import android.content.res.AssetManager +import be.scri.R +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.io.ByteArrayInputStream + +class EmojiHelperTest { + @Test + fun parseRawEmojiSpecsFile_validFile_parsesCorrectly() { + val context = mockk() + val assetManager = mockk() + val specContent = + """ + # Comment line + [smileys_emotion] + 😀;; + 😃;; + + [people_body] + 👋;; + 👋🏻;; + 👋🏼;; + """.trimIndent() + val inputStream = ByteArrayInputStream(specContent.toByteArray()) + + every { context.assets } returns assetManager + every { assetManager.open("emoji_spec.txt") } returns inputStream + + val result = parseRawEmojiSpecsFile(context, "emoji_spec.txt") + + assertEquals(3, result.size) + + assertEquals("smileys_emotion", result[0].category) + assertEquals("😀", result[0].emoji) + assertEquals(0, result[0].variants.size) + + assertEquals("smileys_emotion", result[1].category) + assertEquals("😃", result[1].emoji) + assertEquals(0, result[1].variants.size) + + assertEquals("people_body", result[2].category) + assertEquals("👋", result[2].emoji) + assertEquals(listOf("👋🏻", "👋🏼"), result[2].variants) + } + + @Test + fun parseRawEmojiSpecsFile_emptyAndInvalidLines_ignoresThem() { + val context = mockk() + val assetManager = mockk() + val specContent = + """ + [cat1] + + # comment + emoji1;; + + invalid_line + emoji2;; + """.trimIndent() + val inputStream = ByteArrayInputStream(specContent.toByteArray()) + + every { context.assets } returns assetManager + every { assetManager.open("emoji_spec.txt") } returns inputStream + + val result = parseRawEmojiSpecsFile(context, "emoji_spec.txt") + + assertEquals(2, result.size) + assertEquals("emoji1", result[0].emoji) + assertEquals("emoji2", result[1].emoji) + } + + @Test + fun parseRawEmojiSpecsFile_noCategory_defaultsToNone() { + val context = mockk() + val assetManager = mockk() + val specContent = + """ + emoji1;; + """.trimIndent() + val inputStream = ByteArrayInputStream(specContent.toByteArray()) + + every { context.assets } returns assetManager + every { assetManager.open("emoji_spec.txt") } returns inputStream + + val result = parseRawEmojiSpecsFile(context, "emoji_spec.txt") + + assertEquals(1, result.size) + assertEquals("none", result[0].category) + assertEquals("emoji1", result[0].emoji) + } + + @Test + fun getCategoryIconRes_knownCategories_returnsCorrectIcon() { + assertEquals(R.drawable.ic_emoji_smileys, getCategoryIconRes("smileys_emotion")) + assertEquals(R.drawable.ic_emoji_people, getCategoryIconRes("people_body")) + assertEquals(R.drawable.ic_emoji_animals, getCategoryIconRes("animals_nature")) + assertEquals(R.drawable.ic_emoji_food, getCategoryIconRes("food_drink")) + assertEquals(R.drawable.ic_emoji_travel, getCategoryIconRes("travel_places")) + assertEquals(R.drawable.ic_emoji_activities, getCategoryIconRes("activities")) + assertEquals(R.drawable.ic_emoji_objects, getCategoryIconRes("objects")) + assertEquals(R.drawable.ic_emoji_symbols, getCategoryIconRes("symbols")) + assertEquals(R.drawable.ic_emoji_flags, getCategoryIconRes("flags")) + assertEquals(R.drawable.counter_clockwise_icon, getCategoryIconRes("recently_used")) + } + + @Test + fun getCategoryIconRes_unknownCategory_returnsDefaultIcon() { + assertEquals(R.drawable.ic_emoji_vector, getCategoryIconRes("unknown_category")) + } + + @Test + fun parseRawEmojiSpecsFile_firstLineIsVariant_treatsAsBase() { + val context = mockk() + val assetManager = mockk() + val specContent = "\tvariant1;;\n" + val inputStream = ByteArrayInputStream(specContent.toByteArray()) + + every { context.assets } returns assetManager + every { assetManager.open("emoji_spec.txt") } returns inputStream + + val result = parseRawEmojiSpecsFile(context, "emoji_spec.txt") + + assertEquals(1, result.size) + assertEquals("variant1", result[0].emoji) + assertEquals(0, result[0].variants.size) + } +} diff --git a/app/src/testKeyboards/kotlin/be/scri/helpers/AutocompletionHandlerTest.kt b/app/src/testKeyboards/kotlin/be/scri/helpers/AutocompletionHandlerTest.kt new file mode 100644 index 000000000..60c3beaf8 --- /dev/null +++ b/app/src/testKeyboards/kotlin/be/scri/helpers/AutocompletionHandlerTest.kt @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers + +import android.os.Handler +import android.os.Looper +import be.scri.models.ScribeState +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class AutocompletionHandlerTest { + private lateinit var looper: Looper + private lateinit var handler: Handler + + @MockK(relaxed = true) + private lateinit var ime: KeyboardIMEContext + private lateinit var autocompletionHandler: AutocompletionHandler + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + looper = mockk(relaxed = true) + handler = mockk(relaxed = true) + autocompletionHandler = AutocompletionHandler(ime, handler) + } + + @Test + fun buildCompletions_completionsMatchTypedWord_emptyResponse() { + val typedWord = "word" + val completions = listOf("word", "word") + + val result = AutocompletionHandler.buildCompletions(typedWord, completions) + + assert(result.isEmpty()) + } + + @Test + fun buildCompletions_completionsContainTypedWord_removesIt() { + val typedWord = "word" + val completions = listOf("word", "wordy") + + val result = AutocompletionHandler.buildCompletions(typedWord, completions) + + assert(result.size == 1) + assert(result[0] == "wordy") + } + + @Test + fun buildCompletions_completionsContainManyMore_returnsOnlyTwo() { + val typedWord = "word" + val completions = List(10) { "word$it" } + + val result = AutocompletionHandler.buildCompletions(typedWord, completions) + + assert(result.size == 2) + assert(result[0] == "word0") + assert(result[1] == "word1") + } + + @Test + fun processAutocomplete_emptyWord_clearsAutocomplete() { + every { ime.currentState } returns ScribeState.IDLE + + autocompletionHandler.processAutocomplete("") + + verify { ime.clearAutocomplete() } + } + + @Test + fun processAutocomplete_inWrongState_clearsAutocomplete() { + every { ime.currentState } returns ScribeState.PLURAL + + autocompletionHandler.processAutocomplete("word") + + verify { ime.clearAutocomplete() } + } + + @Test + fun processAutocomplete_withWord_updatesTypedWordSuggestion() { + val runnableSlot = slot() + every { handler.postDelayed(capture(runnableSlot), any()) } returns true + + every { ime.currentState } returns ScribeState.IDLE + + val currentWord = "word" + autocompletionHandler.processAutocomplete(currentWord) + + runnableSlot.captured.run() + verify { ime.updateTypedWordSuggestion(currentWord) } + } + + @Test + fun processAutocomplete_withWord_updatesAutocompleteCompletions() { + val runnableSlot = slot() + every { handler.postDelayed(capture(runnableSlot), any()) } returns true + + every { ime.currentState } returns ScribeState.IDLE + + val currentWord = "word" + autocompletionHandler.processAutocomplete(currentWord) + + runnableSlot.captured.run() + + verify { ime.updateAutocompleteCompletions(any()) } + } + + @Test + fun processAutocomplete_calledMultipleTimes_removesPreviousCallbacks() { + every { ime.currentState } returns ScribeState.IDLE + + autocompletionHandler.processAutocomplete("first") + + autocompletionHandler.processAutocomplete("second") + + verify(exactly = 1) { handler.removeCallbacks(any()) } + } + + @Test + fun clearAutocomplete_cancelsPendingTaskAndClearsIme() { + every { ime.currentState } returns ScribeState.IDLE + + autocompletionHandler.processAutocomplete("word") + + autocompletionHandler.clearAutocomplete() + + verify { handler.removeCallbacks(any()) } + verify { ime.clearAutocomplete() } + } + + @Test + fun processAutocomplete_stateChangesBeforeRunnableExecutes_abortsExecution() { + val runnableSlot = slot() + every { handler.postDelayed(capture(runnableSlot), any()) } returns true + + every { ime.currentState } returns ScribeState.IDLE + autocompletionHandler.processAutocomplete("word") + + every { ime.currentState } returns ScribeState.PLURAL + + runnableSlot.captured.run() + + verify(exactly = 0) { ime.getAutocompletions(any(), any(), any()) } + verify(exactly = 0) { ime.updateAutocompleteCompletions(any()) } + } +} diff --git a/app/src/testKeyboards/kotlin/be/scri/helpers/RecentEmojiHelperTest.kt b/app/src/testKeyboards/kotlin/be/scri/helpers/RecentEmojiHelperTest.kt new file mode 100644 index 000000000..6b800959c --- /dev/null +++ b/app/src/testKeyboards/kotlin/be/scri/helpers/RecentEmojiHelperTest.kt @@ -0,0 +1,95 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers + +import android.content.Context +import android.content.SharedPreferences +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class RecentEmojiHelperTest { + @Test + fun getRecentEmojis_returnsCorrectList_whenRecentEmojiListExists() { + val context = mockk() + val mockPreferences = mockk() + + every { mockPreferences.getString("recent_emoji_list", "") } returns "emoji1,emoji2" + every { context.getSharedPreferences("recent_emojis", Context.MODE_PRIVATE) } returns mockPreferences + + assertEquals(listOf("emoji1", "emoji2"), getRecentEmojis(context)) + } + + @Test + fun getRecentEmojis_returnsEmptyList_whenRecentEmojiListDoesNotExist() { + val context = mockk() + val mockPreferences = mockk() + + every { mockPreferences.getString("recent_emoji_list", "") } returns "" + every { context.getSharedPreferences("recent_emojis", Context.MODE_PRIVATE) } returns mockPreferences + + assertEquals(emptyList(), getRecentEmojis(context)) + } + + @Test + fun getRecentEmojis_returnsCorrectList_whenRecentEmojiListIsNull() { + val context = mockk() + val mockPreferences = mockk() + + every { mockPreferences.getString("recent_emoji_list", "") } returns null + every { context.getSharedPreferences("recent_emojis", Context.MODE_PRIVATE) } returns mockPreferences + + assertEquals(emptyList(), getRecentEmojis(context)) + } + + @Test + fun recordRecentEmojis_addsEmojiToRecentList_whenEmojiDoesNotExist() { + val context = mockk() + val mockPreferences = mockk() + val mockEditor = mockk(relaxed = true) + + every { mockPreferences.getString("recent_emoji_list", "") } returns "emoji1,emoji2" + every { mockPreferences.edit() } returns mockEditor + every { context.getSharedPreferences("recent_emojis", Context.MODE_PRIVATE) } returns mockPreferences + + recordRecentEmoji(context, "emoji3") + + verify { mockEditor.putString("recent_emoji_list", "emoji3,emoji1,emoji2") } + } + + @Test + fun recordRecentEmojis_removesOldestEmoji_whenRecentListIsFull() { + val context = mockk() + val mockPreferences = mockk() + val mockEditor = mockk(relaxed = true) + + // Create a list of 30 emojis: "e1,e2,...,e30" + val initialList = (1..30).joinToString(",") { "e$it" } + // Adding "new" should push "e30" out. Result: "new,e1,e2,...,e29" + val expectedList = "new," + (1..29).joinToString(",") { "e$it" } + + every { mockPreferences.getString("recent_emoji_list", "") } returns initialList + every { mockPreferences.edit() } returns mockEditor + every { context.getSharedPreferences("recent_emojis", Context.MODE_PRIVATE) } returns mockPreferences + + recordRecentEmoji(context, "new") + + verify { mockEditor.putString("recent_emoji_list", expectedList) } + } + + @Test + fun recordRecentEmojis_doesNotAddDuplicateEmoji_whenRecentEmojisNull() { + val context = mockk() + val mockPreferences = mockk() + val mockEditor = mockk(relaxed = true) + + every { mockPreferences.getString("recent_emoji_list", "") } returns null + every { mockPreferences.edit() } returns mockEditor + every { context.getSharedPreferences("recent_emojis", Context.MODE_PRIVATE) } returns mockPreferences + + recordRecentEmoji(context, "emoji1") + + verify(exactly = 0) { mockEditor.putString(any(), any()) } + } +} diff --git a/app/src/testKeyboards/kotlin/be/scri/helpers/ui/HintUtilsTest.kt b/app/src/testKeyboards/kotlin/be/scri/helpers/ui/HintUtilsTest.kt new file mode 100644 index 000000000..43da78dd4 --- /dev/null +++ b/app/src/testKeyboards/kotlin/be/scri/helpers/ui/HintUtilsTest.kt @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package be.scri.helpers.ui + +import android.content.Context +import be.scri.helpers.english.ENInterfaceVariables +import be.scri.helpers.french.FRInterfaceVariables +import be.scri.helpers.german.DEInterfaceVariables +import be.scri.helpers.italian.ITInterfaceVariables +import be.scri.helpers.portuguese.PTInterfaceVariables +import be.scri.helpers.russian.RUInterfaceVariables +import be.scri.helpers.spanish.ESInterfaceVariables +import be.scri.helpers.swedish.SVInterfaceVariables +import be.scri.models.ScribeState +import io.mockk.MockKAnnotations +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import java.util.stream.Stream + +class HintUtilsTest { + @MockK(relaxed = true) + private lateinit var context: Context + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @Test + fun getPromptText_textNullAndSelectVerbConjugation_expectEmptyString() { + val promptText = HintUtils.getPromptText(ScribeState.SELECT_VERB_CONJUNCTION, "English", context, null) + assertEquals("", promptText) + } + + @Test + fun getPromptText_textNotNullAndSelectVerbConjugation_expectText() { + val promptText = HintUtils.getPromptText(ScribeState.SELECT_VERB_CONJUNCTION, "English", context, "text") + assertEquals("text", promptText) + } + + @ParameterizedTest + @MethodSource("provideLanguagesAndPluralPrompts") + fun getPromptText_selectPlural_expectPluralPrompt( + language: String, + expectedPrompt: String, + ) { + val promptText = HintUtils.getPromptText(ScribeState.PLURAL, language, context, null) + assertEquals(expectedPrompt, promptText) + } + + @Test + fun getPromptText_selectPluralAndLanguageIsInvalid_expectEnglishDefault() { + val promptText = HintUtils.getPromptText(ScribeState.PLURAL, "FAKEISH", context, null) + assertEquals("Plural :", promptText) + } + + companion object { + @JvmStatic + fun provideLanguagesAndPluralPrompts(): Stream = + Stream.of( + Arguments.of("English", ENInterfaceVariables.PLURAL_PROMPT), + Arguments.of("French", FRInterfaceVariables.PLURAL_PROMPT), + Arguments.of("German", DEInterfaceVariables.PLURAL_PROMPT), + Arguments.of("Italian", ITInterfaceVariables.PLURAL_PROMPT), + Arguments.of("Portuguese", PTInterfaceVariables.PLURAL_PROMPT), + Arguments.of("Russian", RUInterfaceVariables.PLURAL_PROMPT), + Arguments.of("Spanish", ESInterfaceVariables.PLURAL_PROMPT), + Arguments.of("Swedish", SVInterfaceVariables.PLURAL_PROMPT), + ) + } +}