Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ dependencies {
// Unit Testing
// ==========================
testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version")
testImplementation("org.junit.jupiter:junit-jupiter-params:${junit5Version}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can omit the curly braces ($junit5Version) to match the surrounding dependency lines.

testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit5Version")
testRuntimeOnly("org.junit.vintage:junit-vintage-engine:$junit5Version")
testImplementation("io.mockk:mockk:$mockkVersion")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) }
}

/**
Expand Down
14 changes: 9 additions & 5 deletions app/src/keyboards/java/be/scri/helpers/RecentEmojiHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,24 @@ package be.scri.helpers

import android.content.Context

private const val PREFS_NAME = "recent_emojis"
private const val KEY_RECENT = "recent_emoji_list"
const val PREFS_NAME = "recent_emojis"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider making PREFS_NAME and KEY_RECENT internal const val instead of public const val so they don't leak into the public API outside the keyboard module, while remaining accessible to RecentEmojiHelperTest.

const val KEY_RECENT = "recent_emoji_list"
private const val MAX_RECENT = 30

fun recordRecentEmoji(
context: Context,
emoji: String,
) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)

val recentEmojis = prefs.getString(KEY_RECENT, "") ?: ""

val current =
prefs
.getString(KEY_RECENT, "")!!
recentEmojis
.split(",")
.filter { it.isNotBlank() }
.toMutableList()

current.remove(emoji)
current.add(0, emoji)
while (current.size > MAX_RECENT) current.removeAt(current.lastIndex)
Expand All @@ -26,5 +29,6 @@ fun recordRecentEmoji(

fun getRecentEmojis(context: Context): List<String> {
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() }
}
2 changes: 1 addition & 1 deletion app/src/keyboards/java/be/scri/helpers/ui/HintUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 -> ""
}

Expand Down
18 changes: 10 additions & 8 deletions app/src/main/java/be/scri/helpers/EmojiHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,18 @@ fun parseRawEmojiSpecsFile(
path: String,
): MutableList<EmojiData> {
val emojis = mutableListOf<EmojiData>()
var emojiEditorList: MutableList<String>? = null
var emojiEditorList = mutableListOf<String>()
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<String>().toMutableList()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

emojiEditorList.clear() (or emojiEditorList = mutableListOf()) is more idiomatic than emptyList<String>().toMutableList() and avoids allocating intermediate collections.

Also, inside commitEmojiEditorList(), the emojiEditorList.let { ... } block is redundant now that emojiEditorList is non-null and can be simplified.

}

context.assets.open(path).bufferedReader().useLines { lines ->
Expand All @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since emojiEditorList is now a non-null MutableList<String>, both branches perform the exact same operation (appending emoji). You can simplify this to:

Suggested change
emojiEditorList.add(emoji)
emojiEditorList.add(emoji)

} else {
emojiEditorList = mutableListOf(emoji)
}
Expand Down
133 changes: 133 additions & 0 deletions app/src/test/kotlin/be/scri/helpers/EmojiHelperTest.kt
Original file line number Diff line number Diff line change
@@ -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<Context>()
val assetManager = mockk<AssetManager>()
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<Context>()
val assetManager = mockk<AssetManager>()
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<Context>()
val assetManager = mockk<AssetManager>()
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<Context>()
val assetManager = mockk<AssetManager>()
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)
}
}
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looper is declared and mocked in setUp(), but never used anywhere in this test class. It can be safely removed.

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<Looper>(relaxed = true)
handler = mockk<Handler>(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())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For consistency with the other test classes in this PR and to avoid depending on the -ea JVM runtime flag for Kotlin's assert(...), please use JUnit 5 assertions here (e.g. assertTrue(result.isEmpty()) or assertEquals(emptyList<String>(), result)).

}

@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<Runnable>()
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<Runnable>()
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<Runnable>()) }
}

@Test
fun clearAutocomplete_cancelsPendingTaskAndClearsIme() {
every { ime.currentState } returns ScribeState.IDLE

autocompletionHandler.processAutocomplete("word")

autocompletionHandler.clearAutocomplete()

verify { handler.removeCallbacks(any<Runnable>()) }
verify { ime.clearAutocomplete() }
}

@Test
fun processAutocomplete_stateChangesBeforeRunnableExecutes_abortsExecution() {
val runnableSlot = slot<Runnable>()
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()) }
}
}
Loading
Loading