HeliBoard/app/src/test/java/helium314/keyboard/latin/InputLogicTest.kt
2025-05-10 17:37:20 +02:00

1142 lines
47 KiB
Kotlin

// SPDX-License-Identifier: GPL-3.0-only
package helium314.keyboard.latin
import android.inputmethodservice.InputMethodService
import android.os.Bundle
import android.os.Handler
import android.os.Message
import android.text.InputType
import android.view.KeyEvent
import android.view.inputmethod.*
import androidx.core.content.edit
import helium314.keyboard.ShadowInputMethodManager2
import helium314.keyboard.ShadowLocaleManagerCompat
import helium314.keyboard.event.Event
import helium314.keyboard.keyboard.KeyboardSwitcher
import helium314.keyboard.keyboard.MainKeyboardView
import helium314.keyboard.keyboard.internal.keyboard_parser.floris.KeyCode
import helium314.keyboard.latin.ShadowFacilitator2.Companion.lastAddedWord
import helium314.keyboard.latin.SuggestedWords.SuggestedWordInfo
import helium314.keyboard.latin.common.Constants
import helium314.keyboard.latin.common.StringUtils
import helium314.keyboard.latin.inputlogic.InputLogic
import helium314.keyboard.latin.inputlogic.SpaceState
import helium314.keyboard.latin.settings.Settings
import helium314.keyboard.latin.utils.ScriptUtils
import helium314.keyboard.latin.utils.getTimestamp
import helium314.keyboard.latin.utils.prefs
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.shadows.ShadowLog
import java.util.*
import kotlin.math.min
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
@RunWith(RobolectricTestRunner::class)
@Config(shadows = [
ShadowLocaleManagerCompat::class,
ShadowInputMethodManager2::class,
ShadowInputMethodService::class,
ShadowKeyboardSwitcher::class,
ShadowHandler::class,
ShadowFacilitator2::class,
])
class InputLogicTest {
private lateinit var latinIME: LatinIME
private val settingsValues get() = Settings.getValues()
private val inputLogic get() = latinIME.mInputLogic
private val connection: RichInputConnection get() = inputLogic.mConnection
private val composerReader = InputLogic::class.java.getDeclaredField("mWordComposer").apply { isAccessible = true }
private val composer get() = composerReader.get(inputLogic) as WordComposer
private val spaceStateReader = InputLogic::class.java.getDeclaredField("mSpaceState").apply { isAccessible = true }
private val spaceState get() = spaceStateReader.get(inputLogic) as Int
private val beforeComposingReader = RichInputConnection::class.java.getDeclaredField("mCommittedTextBeforeComposingText").apply { isAccessible = true }
private val connectionTextBeforeComposingText get() = (beforeComposingReader.get(connection) as CharSequence).toString()
private val composingReader = RichInputConnection::class.java.getDeclaredField("mComposingText").apply { isAccessible = true }
private val connectionComposingText get() = (composingReader.get(connection) as CharSequence).toString()
@BeforeTest
fun setUp() {
latinIME = Robolectric.setupService(LatinIME::class.java)
// start logging only after latinIME is created, avoids showing the stack traces if library is not found
ShadowLog.setupLogging()
ShadowLog.stream = System.out
}
@Test fun inputCode() {
reset()
input('c')
assertEquals("c", textBeforeCursor)
assertEquals("c", getText())
assertEquals("", textAfterCursor)
assertEquals("c", composingText)
latinIME.mHandler.onFinishInput()
assertEquals("", composingText)
}
@Test fun delete() {
reset()
setText("hello there ")
functionalKeyPress(KeyCode.DELETE)
assertEquals("hello there", text)
assertEquals("there", composingText)
}
@Test fun deleteInsideWord() {
reset()
setText("hello you there")
setCursorPosition(8) // after o in you
functionalKeyPress(KeyCode.DELETE)
assertEquals("hello yu there", text)
// todo: do we really want an empty composing text in this case?
// setting whole word composing will delete text behind cursor
// setting part before cursor as composing may be bad if user just wants to adjust a letter and result is some autocorrect
assertEquals("", composingText)
}
@Test fun insertLetterIntoWord() {
reset()
setText("hello")
setCursorPosition(3) // after first l
input('i')
assertEquals("helilo", getWordAtCursor())
assertEquals("helilo", getText())
assertEquals(4, getCursorPosition())
assertEquals(4, cursor)
assertEquals("", composingText)
}
@Test fun insertLetterIntoWordWithWeirdEditor() {
reset()
currentInputType = 180225 // should not change much, but just to be sure
setText("hello")
setCursorPosition(3, weirdTextField = true) // after first l
input('i')
assertEquals("helilo", getWordAtCursor())
assertEquals("helilo", getText())
assertEquals(4, getCursorPosition())
assertEquals(4, cursor)
}
@Test fun insertLetterIntoOneOfSeveralWords() {
reset()
setText("hello my friend")
setCursorPosition(7) // between m and y
input('a')
assertEquals("may", getWordAtCursor())
assertEquals("hello may friend", getText())
assertEquals(8, getCursorPosition())
assertEquals(8, cursor)
}
// todo: make it work, but it might not be that simple because adding is done in combiner
// https://github.com/Helium314/HeliBoard/issues/214
@Test fun insertLetterIntoWordHangul() {
if (BuildConfig.BUILD_TYPE == "runTests") return
reset()
currentScript = ScriptUtils.SCRIPT_HANGUL
chainInput("ㅛㅎㄹㅎㅕㅛ")
setCursorPosition(3)
input('ㄲ') // fails, as expected from the hangul issue when processing the event in onCodeInput
assertEquals("ㅛㅎㄹㄲ혀ㅛ", getWordAtCursor())
assertEquals("ㅛㅎㄹㄲ혀ㅛ", getText())
assertEquals("ㅛㅎㄹㄲ혀ㅛ", textBeforeCursor + textAfterCursor)
assertEquals(4, getCursorPosition())
assertEquals(4, cursor)
}
// see issue 1447
@Test fun separatorAfterHangul() {
reset()
currentScript = ScriptUtils.SCRIPT_HANGUL
chainInput("ㅛ.")
assertEquals("ㅛ.", text)
}
// see issue 1551 (debug only)
@Test fun deleteHangul() {
reset()
currentScript = ScriptUtils.SCRIPT_HANGUL
setText("ㅛㅛ ")
functionalKeyPress(KeyCode.DELETE)
functionalKeyPress(KeyCode.DELETE)
functionalKeyPress(KeyCode.DELETE)
}
@Test fun separatorUnselectsWord() {
reset()
setText("hello")
assertEquals("hello", composingText)
input('.')
assertEquals("", composingText)
}
@Test fun autospace() {
reset()
setText("hello")
input('.')
input('a')
assertEquals("hello.a", textBeforeCursor)
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
setText("hello")
input('.')
input('a')
assertEquals("hello. a", textBeforeCursor)
}
@Test fun autospaceButWithTextAfter() {
reset()
setText("hello there")
setCursorPosition(5) // after hello
input('.')
input('a')
assertEquals("hello.a", textBeforeCursor)
assertEquals("hello.a there", text)
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
setText("hello there")
setCursorPosition(5) // after hello
input('.')
input('a')
assertEquals("hello. a", textBeforeCursor)
assertEquals("hello. a there", text)
}
@Test fun noAutospaceInUrlField() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
chainInput("example.net")
assertEquals("example. net", text)
lastAddedWord = ""
setText("")
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
chainInput("example.net")
assertEquals("", lastAddedWord)
assertEquals("example.net", text)
assertEquals("example.net", composingText)
}
@Test fun noAutospaceInUrlFieldWhenPickingSuggestion() {
reset()
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
chainInput("exam")
pickSuggestion("example")
assertEquals("example", text)
input('.')
assertEquals("example.", text)
}
@Test fun noAutospaceForDetectedUrl() { // "light" version, should work without url detection
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
chainInput("http://example.net")
assertEquals("http://example.net", text)
assertEquals("http", lastAddedWord)
assertEquals("example.net", composingText)
}
@Test fun noAutospaceForDetectedEmail() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
chainInput("mail@example.com")
assertEquals("mail@example.com", text)
assertEquals("mail@example", lastAddedWord) // todo: do we want this? not really nice, but don't want to be too aggressive with URL detection disabled
assertEquals("com", composingText) // todo: maybe this should still see the whole address as a single word? or don't be too aggressive?
setText("")
lastAddedWord = ""
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("mail@example.com")
assertEquals("", lastAddedWord)
assertEquals("mail@example.com", composingText)
}
@Test fun urlDetectionThings() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("...h")
assertEquals("...h", text)
assertEquals("h", composingText)
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("bla..")
assertEquals("bla..", text)
assertEquals("", composingText)
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("bla.c")
assertEquals("bla.c", text)
assertEquals("bla.c", composingText)
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
latinIME.prefs().edit { putBoolean(Settings.PREF_SHIFT_REMOVES_AUTOSPACE, true) }
input("bla")
input('.')
functionalKeyPress(KeyCode.SHIFT) // should remove the phantom space (in addition to normal effect)
input('c')
assertEquals("bla.c", text)
assertEquals("bla.c", composingText)
}
@Test fun stripSeparatorsBeforeAddingToHistoryWithURLDetection() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("example.com.")
assertEquals("example.com.", composingText)
input(' ')
assertEquals("example.com", lastAddedWord)
}
@Test fun dontSelectConsecutiveSeparatorsWithURLDetection() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("bla..")
assertEquals("", composingText)
assertEquals("bla..", text)
}
@Test fun selectDoesSelect() {
reset()
setText("this is some text")
setCursorPosition(3, 8)
assertEquals("s is ", text.substring(3, 8))
}
@Test fun noComposingForPasswordFields() {
reset()
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)
input('a')
input('b')
assertEquals("", composingText)
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
input('.')
input('c')
assertEquals("", composingText)
}
@Test fun `don't select whole thing as composing word if URL detection disabled`() {
reset()
setText("http://example.com")
setCursorPosition(13) // between l and e
assertEquals("example", composingText)
}
@Test fun `select whole thing except http(s) as composing word if URL detection enabled and selecting`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setText("http://example.com")
setCursorPosition(13) // between l and e
assertEquals("example.com", composingText)
setText("http://bla.com http://example.com ")
setCursorPosition(29) // between l and e
assertEquals("example.com", composingText)
}
@Test fun `select whole thing except http(s) as composing word if URL detection enabled and typing`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("http://example.com")
assertEquals("example.com", composingText)
}
@Test fun `don't add partial URL to history`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setText("http:/") // just so lastAddedWord isn't set to http
chainInput("/bla.com")
assertEquals("", lastAddedWord)
}
@Test fun urlProperlySelected() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
setText("http://example.com/here")
setCursorPosition(18) // after .com
functionalKeyPress(KeyCode.DELETE)
functionalKeyPress(KeyCode.DELETE)
functionalKeyPress(KeyCode.DELETE) // delete com
// todo: do we really want no composing text?
// probably not... try not to break composing
assertEquals("", composingText)
chainInput("net")
assertEquals("example.net", composingText)
}
@Test fun urlProperlySelectedWhenNotDeletingFullTld() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setText("http://example.com/here")
setCursorPosition(18) // after .com
functionalKeyPress(KeyCode.DELETE)
functionalKeyPress(KeyCode.DELETE) // delete om
// todo: this is a weird difference to deleting the full TLD (see urlProperlySelected)
// what do we want here? (probably consistency)
assertEquals("example.c/here", composingText)
chainInput("z")
assertEquals("", composingText) // todo: this is a weird difference to deleting the full TLD
// assertEquals("example.cz", composingText) // fails, but probably would be better than above
}
@Test fun dontCommitPartialUrlBeforeFirstPeriod() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
// type http://bla. -> bla not selected, but clearly url, also means http://bla is committed which we probably don't want
chainInput("http://bla.")
assertEquals("bla.", composingText)
}
@Test fun `intermediate commits in text field without protocol`() {
reset()
chainInput("bla.")
assertEquals("bla", lastAddedWord)
chainInput("com/")
assertEquals("com", lastAddedWord)
chainInput("img.jpg")
assertEquals("img", lastAddedWord)
assertEquals("jpg", composingText)
}
@Test fun `intermediate commit in text field without protocol and with URL detection`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("bla.com/img.jpg")
assertEquals("bla", lastAddedWord)
assertEquals("bla.com/img.jpg", composingText)
}
@Test fun `only protocol commit in text field with protocol and URL detection`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("http://bla.com/img.jpg")
assertEquals("http", lastAddedWord)
assertEquals("bla.com/img.jpg", composingText)
}
@Test fun `no intermediate commit in URL field with protocol`() {
reset()
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
chainInput("http://bla.com/img.jpg")
assertEquals("http", lastAddedWord) // todo: somehow avoid?
assertEquals("http://bla.com/img.jpg", text)
assertEquals("bla.com/img.jpg", composingText)
}
@Test fun `no intermediate commit in URL field with protocol and URL detection`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
chainInput("http://bla.com/img.jpg")
assertEquals("http", lastAddedWord) // todo: somehow avoid?
assertEquals("http://bla.com/img.jpg", text)
assertEquals("bla.com/img.jpg", composingText)
}
@Test fun `no intermediate commit in URL field without protocol`() {
reset()
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
chainInput("bla.com/img.jpg")
assertEquals("", lastAddedWord)
assertEquals("bla.com/img.jpg", text)
assertEquals("bla.com/img.jpg", composingText)
}
@Test fun `no intermediate commit in URL field without protocol and with URL detection`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
chainInput("bla.com/img.jpg")
assertEquals("", lastAddedWord)
assertEquals("bla.com/img.jpg", text)
assertEquals("bla.com/img.jpg", composingText)
}
@Test fun `don't accidentally detect some other text fields as URI`() {
// see comment in InputLogic.textBeforeCursorMayBeUrlOrSimilar
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_LONG_MESSAGE)
chainInput("Hey,why")
assertEquals("Hey, why", text)
}
@Test fun `URL detection does not trigger on non-words`() {
// first make sure it works without URL detection
reset()
chainInput("15:50-17")
assertEquals("15:50-17", text)
assertEquals("", composingText)
// then with URL detection
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
chainInput("15:50-17")
assertEquals("15:50-17", text)
assertEquals("", composingText)
}
@Test fun `autospace after selecting a suggestion`() {
reset()
pickSuggestion("this")
input('b')
assertEquals("this b", text)
assertEquals("b", composingText)
}
@Test fun `autospace works in URL field when input isn't URL`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
pickSuggestion("this")
input('b')
assertEquals("this b", text)
assertEquals("b", composingText)
}
// https://github.com/Helium314/HeliBoard/issues/215
// https://github.com/Helium314/HeliBoard/issues/229
@Test fun `autospace works in URL field when input isn't URL, also for multiple suggestions`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
pickSuggestion("this")
pickSuggestion("is")
assertEquals("this is", text)
pickSuggestion("not")
assertEquals("this is not", text)
input('c')
assertEquals("this is not c", text)
assertEquals("c", composingText)
}
@Test fun `emoji is added to dictionary`() {
// check both text and codepoint input
reset()
chainInput("hello ")
input(0x1F36D)
assertEquals(StringUtils.newSingleCodePointString(0x1F36D), lastAddedWord)
reset()
chainInput("hello ")
input("🤗")
assertEquals("\uD83E\uDD17", lastAddedWord)
reset()
chainInput("hello ")
input("why 🤗 ") // not added because it's not only emoji (input can come from pasting)
assertEquals("hello", lastAddedWord)
}
@Test fun `emoji uses phantom space`() {
// check both text and codepoint input
reset()
pickSuggestion("hi")
input("🤗")
assertEquals("\uD83E\uDD17", lastAddedWord)
assertEquals("hi \uD83E\uDD17", text)
reset()
pickSuggestion("hi")
input(0x1F36D)
assertEquals(StringUtils.newSingleCodePointString(0x1F36D), lastAddedWord)
assertEquals("hi ${StringUtils.newSingleCodePointString(0x1F36D)}", text)
}
// https://github.com/Helium314/HeliBoard/issues/230
@Test fun `no autospace after opening quotes`() {
reset()
chainInput("\"Hi\" \"h")
assertEquals("\"Hi\" \"h", text)
assertEquals("h", composingText)
reset()
chainInput("\"Hi\", \"h")
assertEquals("\"Hi\", \"h", text)
assertEquals("h", composingText)
}
@Test fun `autospace works in URL field when starting with quotes`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_URL_DETECTION, true) }
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI)
input("\"")
pickSuggestion("this")
input("i")
assertEquals("\"this i", text)
}
@Test fun `double space results in period and space, and delete removes the period`() {
reset()
chainInput("hello")
input(' ')
input(' ')
assertEquals("hello. ", text)
functionalKeyPress(KeyCode.DELETE)
assertEquals("hello ", text)
}
@Test fun `no weird space inside multi-"`() {
reset()
chainInput("\"\"\"")
assertEquals("\"\"\"", text)
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
chainInput("\"\"\"")
assertEquals("\"\"\"", text)
}
@Test fun `autospace still happens after "`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
chainInput("\"hello\"you")
assertEquals("\"hello\" you", text)
}
@Test fun `autospace still happens after " if next word is in quotes`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
chainInput("\"hello\"\"you\"")
assertEquals("\"hello\" \"you\"", text)
}
@Test fun `autospace propagates over "`() {
reset()
input('"')
pickSuggestion("hello")
assertEquals(spaceState, SpaceState.PHANTOM) // picking a suggestion sets phantom space state
chainInput("\"you")
assertEquals("\"hello\" you", text)
}
@Test fun `autospace still happens after " if nex word is in " and after comma`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
chainInput("\"hello\",\"you\"")
assertEquals("\"hello\", \"you\"", text)
}
@Test fun `autospace in json editor`() {
reset()
latinIME.prefs().edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, true) }
chainInput("{\"label\":\"")
assertEquals("{\"label\": \"", text)
input('c')
assertEquals("{\"label\": \"c", text)
}
@Test fun `text input and delete`() {
reset()
input("hello")
assertEquals("hello", text)
functionalKeyPress(KeyCode.DELETE)
assertEquals("hell", text)
reset()
input("hello ")
assertEquals("hello ", text)
functionalKeyPress(KeyCode.DELETE)
assertEquals("hello", text)
}
@Test fun `emoji text input and delete`() {
reset()
input("🕵🏼")
functionalKeyPress(KeyCode.DELETE)
assertEquals("", text)
reset()
input("\uD83D\uDD75\uD83C\uDFFC")
input(' ')
assertEquals("🕵🏼 ", text)
functionalKeyPress(KeyCode.DELETE)
functionalKeyPress(KeyCode.DELETE)
assertEquals("", text)
}
@Test fun `revert autocorrect on delete`() {
reset()
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT)
chainInput("hullo")
getAutocorrectedWithSpaceAfter("hello", "hullo")
assertEquals("hello ", text)
functionalKeyPress(KeyCode.DELETE)
assertEquals("hullo", text)
reset()
setInputType(InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_FLAG_AUTO_CORRECT)
latinIME.prefs().edit { putBoolean(Settings.PREF_BACKSPACE_REVERTS_AUTOCORRECT, false) }
chainInput("hullo")
getAutocorrectedWithSpaceAfter("hello", "hullo")
functionalKeyPress(KeyCode.DELETE)
assertEquals("hello", text)
}
@Test fun `remove glide typing word on delete`() {
reset()
glideTypingInput("hello")
assertEquals("hello", text)
functionalKeyPress(KeyCode.DELETE)
assertEquals("", text)
// todo: now we want some way to disable delete-all on backspace, either per setting or something else
// need to avoid getting into the mWordComposer.isBatchMode() part of handleBackspaceEvent
}
@Test fun timestamp() {
reset()
chainInput("hello")
functionalKeyPress(KeyCode.TIMESTAMP)
assertEquals("hello" + getTimestamp(latinIME), text)
}
// ------- helper functions ---------
// should be called before every test, so the same state is guaranteed
private fun reset() {
// reset input connection & facilitator
currentScript = ScriptUtils.SCRIPT_LATIN
text = ""
batchEdit = 0
currentInputType = InputType.TYPE_CLASS_TEXT
lastAddedWord = ""
// reset settings
latinIME.prefs().edit { clear() }
setText("") // (re)sets selection and composing word
}
private fun chainInput(text: String) = text.forEach { input(it.code) }
private fun input(char: Char) = input(char.code)
private fun input(codePoint: Int) {
require(codePoint > 0) { "not a codePoint: $codePoint" }
val oldBefore = textBeforeCursor
val oldAfter = textAfterCursor
val insert = StringUtils.newSingleCodePointString(codePoint)
val phantomSpaceToInsert = if (spaceState == SpaceState.PHANTOM) " " else ""
latinIME.onEvent(Event.createEventForCodePointFromUnknownSource(codePoint))
handleMessages()
if (currentScript != ScriptUtils.SCRIPT_HANGUL // check fails if hangul combiner merges symbols
&& !(codePoint == Constants.CODE_SPACE && oldBefore.lastOrNull() == ' ') // check fails when 2 spaces are converted into a period
&& !latinIME.mInputLogic.mSuggestedWords.mWillAutoCorrect // autocorrect obviously creates inconsistencies
) {
if (phantomSpaceToInsert.isEmpty())
assertEquals(oldBefore + insert, textBeforeCursor)
else // in some cases autospace might be suppressed
assert(oldBefore + phantomSpaceToInsert + insert == textBeforeCursor || oldBefore + insert == textBeforeCursor)
}
assertEquals(oldAfter, textAfterCursor)
assertEquals(textBeforeCursor + textAfterCursor, getText())
checkConnectionConsistency()
}
private fun functionalKeyPress(keyCode: Int) {
require(keyCode < 0) { "not a functional key code: $keyCode" }
latinIME.onEvent(Event.createSoftwareKeypressEvent(Event.NOT_A_CODE_POINT, keyCode, 0, Constants.NOT_A_COORDINATE, Constants.NOT_A_COORDINATE, false))
handleMessages()
checkConnectionConsistency()
}
// almost the same as codePoint input, but calls different latinIME function
private fun input(insert: String) {
val oldBefore = textBeforeCursor
val oldAfter = textAfterCursor
val phantomSpaceToInsert = if (spaceState == SpaceState.PHANTOM) " " else ""
latinIME.onTextInput(insert)
handleMessages()
if (phantomSpaceToInsert.isEmpty())
assertEquals(oldBefore + insert, textBeforeCursor)
else // in some cases autospace might be suppressed
assert(oldBefore + phantomSpaceToInsert + insert == textBeforeCursor || oldBefore + insert == textBeforeCursor)
assert(oldBefore + insert == textBeforeCursor || "$oldBefore $insert" == textBeforeCursor)
assertEquals(oldAfter, textAfterCursor)
assertEquals(textBeforeCursor + textAfterCursor, getText())
checkConnectionConsistency()
}
private fun getWordAtCursor() = connection.getWordRangeAtCursor(settingsValues.mSpacingAndPunctuations, currentScript)?.mWord
private fun setCursorPosition(start: Int, end: Int = start, weirdTextField: Boolean = false) {
val ei = EditorInfo()
ei.inputType = currentInputType
ei.initialSelStart = start
ei.initialSelEnd = end
// imeOptions should not matter
// adjust text in inputConnection first, otherwise fixLyingCursorPosition will move cursor
// to the end of the text
val fullText = textBeforeCursor + selectedText + textAfterCursor
assertEquals(fullText, getText())
// need to update ic before, otherwise when reloading text cache from ic, ric will load wrong text before cursor
val oldStart = selectionStart
val oldEnd = selectionEnd
selectionStart = start
selectionEnd = end
assertEquals(fullText, textBeforeCursor + selectedText + textAfterCursor)
latinIME.onUpdateSelection(oldStart, oldEnd, start, end, composingStart, composingEnd)
handleMessages()
if (weirdTextField) {
latinIME.mHandler.onStartInput(ei, true) // essentially does nothing
latinIME.mHandler.onStartInputView(ei, true) // does the thing
handleMessages()
}
assertEquals(fullText, getText())
assertEquals(start, selectionStart)
assertEquals(end, selectionEnd)
checkConnectionConsistency()
}
// assumes we have nothing selected
private fun getCursorPosition(): Int {
assertEquals(cursor, connection.expectedSelectionStart)
assertEquals(cursor, connection.expectedSelectionEnd)
return cursor
}
// just sets the text and starts input so connection it set up correctly
private fun setText(newText: String) {
text = newText
selectionStart = newText.length
selectionEnd = selectionStart
composingStart = -1
composingStart = -1
// we need to start input to notify that something changed
// restarting is false, so this is seen as a new text field
val ei = EditorInfo()
ei.inputType = currentInputType
latinIME.mHandler.onStartInput(ei, false)
latinIME.mHandler.onStartInputView(ei, false)
handleMessages() // this is important so the composing span is set correctly
checkConnectionConsistency()
}
// like selecting a suggestion from strip
private fun pickSuggestion(suggestion: String) {
val info = SuggestedWordInfo(suggestion, "", 0, 0, null, 0, 0)
latinIME.pickSuggestionManually(info)
checkConnectionConsistency()
}
// only works when autocorrect is on, separator after word is required
private fun getAutocorrectedWithSpaceAfter(suggestion: String, typedWord: String?) {
val info = SuggestedWordInfo(suggestion, "", 0, 0, null, 0, 0)
val typedInfo = SuggestedWordInfo(typedWord, "", 0, 0, null, 0, 0)
val sw = SuggestedWords(ArrayList(listOf(typedInfo, info)), null, typedInfo, false, true, false, 0, 0)
latinIME.mInputLogic.setSuggestedWords(sw) // this prepares for autocorrect
input(' ')
checkConnectionConsistency()
}
private fun glideTypingInput(word: String) {
val info = SuggestedWordInfo(word, "", 0, 0, null, 0, 0)
val sw = SuggestedWords(ArrayList(listOf(info)), null, info, true, false, false, 0, 0)
latinIME.mInputLogic.onUpdateTailBatchInputCompleted(settingsValues, sw, KeyboardSwitcher.getInstance())
}
private fun checkConnectionConsistency() {
// RichInputConnection only has composing text up to cursor, but InputConnection has full composing text
val expectedConnectionComposingText = if (composingStart == -1 || composingEnd == -1) ""
else text.substring(composingStart, min(composingEnd, selectionEnd))
assert(composingText.startsWith(expectedConnectionComposingText))
// RichInputConnection only returns text up to cursor
val textBeforeComposingText = if (composingStart == -1) textBeforeCursor else text.substring(0, composingStart)
println("consistency: $selectionStart, ${connection.expectedSelectionStart}, $selectionEnd, ${connection.expectedSelectionEnd}, $textBeforeComposingText, " +
"$connectionTextBeforeComposingText, $composingText, $connectionComposingText, $textBeforeCursor, ${connection.getTextBeforeCursor(textBeforeCursor.length, 0)}" +
", $textAfterCursor, ${connection.getTextAfterCursor(textAfterCursor.length, 0)}")
assertEquals(selectionStart, connection.expectedSelectionStart)
assertEquals(selectionEnd, connection.expectedSelectionEnd)
assertEquals(textBeforeComposingText, connectionTextBeforeComposingText)
assertEquals(expectedConnectionComposingText, connectionComposingText)
assertEquals(textBeforeCursor, connection.getTextBeforeCursor(textBeforeCursor.length, 0).toString())
assertEquals(textAfterCursor, connection.getTextAfterCursor(textAfterCursor.length, 0).toString())
}
private fun getText() =
connection.getTextBeforeCursor(100, 0).toString() + (connection.getSelectedText(0) ?: "") + connection.getTextAfterCursor(100, 0)
private fun setInputType(inputType: Int) {
// set text to actually apply input type
currentInputType = inputType
setText(text)
}
// always need to handle messages for proper simulation
private fun handleMessages() {
while (messages.isNotEmpty()) {
latinIME.mHandler.handleMessage(messages.first())
messages.removeAt(0)
}
while (delayedMessages.isNotEmpty()) {
val msg = delayedMessages.first()
if (msg.what != 2) // MSG_UPDATE_SUGGESTION_STRIP, we want to ignore it because it's irrelevant and has a 500 ms timeout
latinIME.mHandler.handleMessage(delayedMessages.first())
delayedMessages.removeAt(0)
// delayed messages may post further messages, handle before next delayed message
while (messages.isNotEmpty()) {
latinIME.mHandler.handleMessage(messages.first())
messages.removeAt(0)
}
}
assertEquals(0, messages.size)
assertEquals(0, delayedMessages.size)
}
}
private var currentInputType = InputType.TYPE_CLASS_TEXT
private var currentScript = ScriptUtils.SCRIPT_LATIN
private val messages = mutableListOf<Message>() // for latinIME / ShadowInputMethodService
private val delayedMessages = mutableListOf<Message>() // for latinIME / ShadowInputMethodService
// inputconnection stuff
private var batchEdit = 0
private var text = ""
private var selectionStart = 0
private var selectionEnd = 0
private var composingStart = -1
private var composingEnd = -1
// convenience for access
private val textBeforeCursor get() = text.substring(0, selectionStart)
private val textAfterCursor get() = text.substring(selectionEnd)
private val selectedText get() = text.substring(selectionStart, selectionEnd)
private val cursor get() = if (selectionStart == selectionEnd) selectionStart else -1
// composingText should return everything, but RichInputConnection.mComposingText only returns up to cursor
private val composingText get() = if (composingStart == -1 || composingEnd == -1) ""
else text.substring(composingStart, composingEnd)
// essentially this is the text field we're editing in
private val ic = object : InputConnection {
// pretty clear (though this may be slow depending on the editor)
// bad return value here is likely the cause for that weird bug improved/fixed by fixIncorrectLength
override fun getTextBeforeCursor(p0: Int, p1: Int): CharSequence = textBeforeCursor.take(p0)
// pretty clear (though this may be slow depending on the editor)
override fun getTextAfterCursor(p0: Int, p1: Int): CharSequence = textAfterCursor.take(p0)
// pretty clear
override fun getSelectedText(p0: Int): CharSequence? = if (selectionStart == selectionEnd) null
else text.substring(selectionStart, selectionEnd)
// inserts text at cursor (right?), and sets it as composing text
// this REPLACES currently composing text (even if at a different position)
// moves the cursor: positive means relative to composing text start, negative means relative to start
override fun setComposingText(newText: CharSequence, cursor: Int): Boolean {
// first remove the composing text if any
if (composingStart != -1 && composingEnd != -1)
text = text.substring(0, composingStart) + text.substring(composingEnd)
else // no composing span active, we should remove selected text
if (selectionStart != selectionEnd) {
text = textBeforeCursor + textAfterCursor
selectionEnd = selectionStart
}
// then set the new text at old composing start
// if no composing start, set it at cursor position
val insertStart = if (composingStart == -1) selectionStart else composingStart
text = text.substring(0, insertStart) + newText + text.substring(insertStart)
composingStart = insertStart
composingEnd = insertStart + newText.length
// the cursor -1 is not clear in documentation, but
// "So a value of 1 will always advance you to the position after the full text being inserted"
// means that 1 must be composingEnd
selectionStart = if (cursor > 0) composingEnd + cursor - 1
else -cursor
selectionEnd = selectionStart
// todo: this should call InputMethodManager#updateSelection(View, int, int, int, int)
// but only after batch edit has ended
// this is not used in RichInputMethodManager, but probably ends up in LatinIME.onUpdateSelection
// -> DO IT (though it will likely only trigger that belatedSelectionUpdate thing, it might be relevant)
return true
}
override fun setComposingRegion(p0: Int, p1: Int): Boolean {
println("setComposingRegion, $p0, $p1")
composingStart = p0
composingEnd = p1
return true // never checked
}
// sets composing text empty, but doesn't change actual text
override fun finishComposingText(): Boolean {
composingStart = -1
composingEnd = -1
return true // always true
}
// as per documentation: "This behaves like calling setComposingText(text, newCursorPosition) then finishComposingText()"
override fun commitText(p0: CharSequence, p1: Int): Boolean {
setComposingText(p0, p1)
finishComposingText()
return true // whether we added the text
}
// just tells the text field that we add many updated, and that the editor should not
// send status updates until batch edit ended (not actually used for this simulation)
override fun beginBatchEdit(): Boolean {
++batchEdit
return true // always true
}
// end a batch edit, but maybe there are multiple batch edits happening
override fun endBatchEdit(): Boolean {
if (batchEdit > 0)
return --batchEdit == 0
return false // returns true if there is still a batch edit ongoing
}
// should notify about cursor info containing composing text, selection, ...
// todo: maybe that could be interesting, implement it?
override fun requestCursorUpdates(p0: Int): Boolean {
// we call this, but don't have onUpdateCursorAnchorInfo overridden in latinIME, so it does nothing
// also currently we don't care about the return value
return false
}
override fun setSelection(p0: Int, p1: Int): Boolean {
selectionStart = p0
selectionEnd = p1
// todo: call InputMethodService.onUpdateSelection(int, int, int, int, int, int), but only after batch edit is done!
return true
}
// delete beforeLength before cursor position, and afterLength after cursor position
// chars, not codepoints or glyphs
// todo: may delete only one half of a surrogate pair, but this should be avoided by RichInputConnection (maybe throw error)
override fun deleteSurroundingText(beforeLength: Int, afterLength: Int): Boolean {
// delete only before or after selection
text = textBeforeCursor.substring(0, textBeforeCursor.length - beforeLength) +
text.substring(selectionStart, selectionEnd) +
textAfterCursor.substring(afterLength)
// if parts of the composing span are deleted, shorten the span (set end to shorter)
if (selectionStart <= composingStart) {
composingStart -= beforeLength // is this correct?
composingEnd -= beforeLength
} else if (selectionStart <= composingEnd) {
composingEnd -= beforeLength // is this correct?
}
if (selectionEnd <= composingStart) {
composingStart -= afterLength
composingEnd -= afterLength
} else if (selectionEnd <= composingEnd) {
composingEnd -= afterLength
}
// update selection
selectionStart -= beforeLength
selectionEnd -= beforeLength
return true
}
override fun sendKeyEvent(p0: KeyEvent): Boolean {
if (p0.action != KeyEvent.ACTION_DOWN) return true // only change the text on key down, like RichInputConnection does
if (p0.keyCode == KeyEvent.KEYCODE_DEL) {
if (selectionEnd == 0) return true // nothing to delete
if (selectedText.isEmpty()) {
text = text.substring(0, selectionStart - 1) + text.substring(selectionEnd)
selectionStart -= 1
} else {
text = text.substring(0, selectionStart) + text.substring(selectionEnd)
}
selectionEnd = selectionStart
return true
}
val textToAdd = when (p0.keyCode) {
KeyEvent.KEYCODE_ENTER -> "\n"
KeyEvent.KEYCODE_DEL -> null
KeyEvent.KEYCODE_UNKNOWN -> p0.characters
else -> StringUtils.newSingleCodePointString(p0.unicodeChar)
}
if (textToAdd != null) {
text = text.substring(0, selectionStart) + textToAdd + text.substring(selectionEnd)
selectionStart += textToAdd.length
selectionEnd = selectionStart
composingStart = -1
composingEnd = -1
}
return true
}
// implementation is only to work with getTextBeforeCursorAndDetectLaggyConnection
override fun getExtractedText(p0: ExtractedTextRequest?, p1: Int): ExtractedText {
return ExtractedText().also {
it.startOffset = 0
it.selectionStart = selectionStart
it.selectionEnd = selectionEnd
}
}
// only effect is flashing, so whatever...
override fun commitCorrection(p0: CorrectionInfo?): Boolean = true
// implement only when necessary
override fun getCursorCapsMode(p0: Int): Int = TODO("Not yet implemented")
override fun deleteSurroundingTextInCodePoints(p0: Int, p1: Int): Boolean = TODO("Not yet implemented")
override fun commitCompletion(p0: CompletionInfo?): Boolean = TODO("Not yet implemented")
override fun performEditorAction(p0: Int): Boolean = TODO("Not yet implemented")
override fun performContextMenuAction(p0: Int): Boolean = TODO("Not yet implemented")
override fun clearMetaKeyStates(p0: Int): Boolean = TODO("Not yet implemented")
override fun reportFullscreenMode(p0: Boolean): Boolean = TODO("Not yet implemented")
override fun performPrivateCommand(p0: String?, p1: Bundle?): Boolean = TODO("Not yet implemented")
override fun getHandler(): Handler = TODO("Not yet implemented")
override fun closeConnection() = TODO("Not yet implemented")
override fun commitContent(p0: InputContentInfo, p1: Int, p2: Bundle?): Boolean = TODO("Not yet implemented")
}
// Shadows are handled by Robolectric. @Implementation overrides built-in functionality.
// This is used for avoiding crashes (LocaleManagerCompat, InputMethodManager, KeyboardSwitcher)
// and for simulating system stuff (InputMethodService for controlling the InputConnection, which
// more or less is the contents of the text field), and for setting the current script in
// KeyboardSwitcher without having to care about InputMethodSubtypes
// could also extend LatinIME, it's not final anyway
@Implements(InputMethodService::class)
class ShadowInputMethodService {
@Implementation
fun getCurrentInputEditorInfo() = EditorInfo().apply {
inputType = currentInputType
// anything else?
}
@Implementation
fun getCurrentInputConnection() = ic
@Implementation
fun isInputViewShown() = true // otherwise selection updates will do nothing
}
@Implements(Handler::class)
class ShadowHandler {
@Implementation
fun sendMessage(message: Message) {
messages.add(message)
}
@Implementation
fun sendMessageDelayed(message: Message, delay: Long) {
delayedMessages.add(message)
}
}
@Implements(KeyboardSwitcher::class)
class ShadowKeyboardSwitcher {
@Implementation
// basically only needed for null check
fun getMainKeyboardView(): MainKeyboardView = Mockito.mock(MainKeyboardView::class.java)
@Implementation
// only affects view
fun setKeyboard(keyboardId: Int, toggleState: KeyboardSwitcher.KeyboardSwitchState) = Unit
@Implementation
// only affects view
fun setOneHandedModeEnabled(enabled: Boolean) = Unit
@Implementation
fun getCurrentKeyboardScript() = currentScript
}
@Implements(DictionaryFacilitatorImpl::class)
class ShadowFacilitator2 {
@Implementation
fun addToUserHistory(suggestion: String, wasAutoCapitalized: Boolean,
ngramContext: NgramContext, timeStampInSeconds: Long,
blockPotentiallyOffensive: Boolean) {
lastAddedWord = suggestion
}
companion object {
var lastAddedWord = ""
}
}