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}")
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
28 changes: 17 additions & 11 deletions app/src/keyboards/java/be/scri/helpers/RecentEmojiHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<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()
}

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

@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