add unit tests for input logic (wip very much)

This commit is contained in:
Helium314 2023-09-19 19:39:47 +02:00
parent f1fde08a55
commit 70f419efe5
7 changed files with 590 additions and 270 deletions

View file

@ -56,6 +56,12 @@ android {
useLegacyPackaging true
}
}
testOptions {
unitTests {
includeAndroidResources = true
}
}
}
dependencies {
@ -64,5 +70,11 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.github.skydoves:colorpickerview:2.2.4'
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:3.12.4'
testImplementation 'org.mockito:mockito-inline:3.12.4'
testImplementation 'org.robolectric:robolectric:4.9'
testImplementation 'androidx.test:runner:1.5.2'
testImplementation 'androidx.test:core:1.4.0'
}

View file

@ -24,7 +24,8 @@ private class CrashReportExceptionHandler(val appContext: Context) : Thread.Unca
fun install(): Boolean {
val ueh = Thread.getDefaultUncaughtExceptionHandler()
check(ueh !is CrashReportExceptionHandler) { "May not install several CrashReportExceptionHandlers!" }
if (ueh is CrashReportExceptionHandler)
return false
defaultUncaughtExceptionHandler = ueh
Thread.setDefaultUncaughtExceptionHandler(this)
return true

View file

@ -18,6 +18,7 @@ package org.dslul.openboard.inputmethod.latin;
import android.text.TextUtils;
import org.dslul.openboard.inputmethod.annotations.UsedForTesting;
import org.dslul.openboard.inputmethod.keyboard.Keyboard;
import org.dslul.openboard.inputmethod.keyboard.KeyboardId;
import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
@ -203,8 +204,6 @@ public final class Suggest {
keyboard.mId.mMode,
wordComposer,
suggestionResults,
mDictionaryFacilitator,
mAutoCorrectionThreshold,
firstOccurrenceOfTypedWordInSuggestions,
typedWordFirstOccurrenceWordInfo
);
@ -282,7 +281,8 @@ public final class Suggest {
}
// returns [allowsToBeAutoCorrected, hasAutoCorrection]
static boolean[] shouldBeAutoCorrected(
@UsedForTesting
boolean[] shouldBeAutoCorrected(
final int trailingSingleQuotesCount,
final String typedWordString,
final List<SuggestedWordInfo> suggestionsContainer,
@ -293,8 +293,6 @@ public final class Suggest {
final int keyboardIdMode,
final WordComposer wordComposer,
final SuggestionResults suggestionResults,
final DictionaryFacilitator dictionaryFacilitator,
final float autoCorrectionThreshold,
final int firstOccurrenceOfTypedWordInSuggestions,
final SuggestedWordInfo typedWordFirstOccurrenceWordInfo
) {
@ -365,7 +363,7 @@ public final class Suggest {
// list, "will" would always auto-correct to "Will" which is unwanted. Hence, no
// main dict => no auto-correct. Also, it would probably get obnoxious quickly.
// TODO: now that we have personalization, we may want to re-evaluate this decision
|| !dictionaryFacilitator.hasAtLeastOneInitializedMainDictionary()) {
|| !mDictionaryFacilitator.hasAtLeastOneInitializedMainDictionary()) {
hasAutoCorrection = false;
} else {
final SuggestedWordInfo firstSuggestion = suggestionResults.first();
@ -376,7 +374,7 @@ public final class Suggest {
return new boolean[]{ true, true };
}
if (!AutoCorrectionUtils.suggestionExceedsThreshold(
firstSuggestion, consideredWord, autoCorrectionThreshold)) {
firstSuggestion, consideredWord, mAutoCorrectionThreshold)) {
// todo: maybe also do something here depending on ngram context?
// Score is too low for autocorrect
return new boolean[]{ true, false };
@ -390,7 +388,7 @@ public final class Suggest {
// typed word is valid and has good score
// do not auto-correct if typed word is better match than first suggestion
final SuggestedWordInfo first = firstSuggestionInContainer != null ? firstSuggestionInContainer : firstSuggestion;
final Locale dictLocale = dictionaryFacilitator.getCurrentLocale();
final Locale dictLocale = mDictionaryFacilitator.getCurrentLocale();
if (first.mScore < scoreLimit) {
// don't allow if suggestion has too low score

View file

@ -16,18 +16,11 @@
package org.dslul.openboard.inputmethod.latin.utils;
import static android.view.KeyEvent.KEYCODE_SPACE;
import android.annotation.SuppressLint;
import android.os.Build;
import android.util.Log;
import com.android.inputmethod.latin.utils.BinaryDictionaryUtils;
import org.dslul.openboard.inputmethod.annotations.UsedForTesting;
import org.dslul.openboard.inputmethod.latin.BuildConfig;
import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo;
import org.dslul.openboard.inputmethod.latin.common.StringUtils;
import org.dslul.openboard.inputmethod.latin.define.DebugFlags;
public final class AutoCorrectionUtils {
@ -38,7 +31,6 @@ public final class AutoCorrectionUtils {
// Purely static class: can't instantiate.
}
@SuppressLint("ObsoleteSdkInt") // SDK_INT is 0 in unit tests
public static boolean suggestionExceedsThreshold(final SuggestedWordInfo suggestion,
final String consideredWord, final float threshold) {
if (null != suggestion) {
@ -53,13 +45,7 @@ public final class AutoCorrectionUtils {
final int autoCorrectionSuggestionScore = suggestion.mScore;
// TODO: when the normalized score of the first suggestion is nearly equals to
// the normalized score of the second suggestion, behave less aggressive.
final float normalizedScore;
if (BuildConfig.DEBUG && Build.VERSION.SDK_INT == 0)
normalizedScore = calcNormalizedScore(StringUtils.toCodePointArray(consideredWord),
StringUtils.toCodePointArray(suggestion.mWord), autoCorrectionSuggestionScore,
editDistance(consideredWord, suggestion.mWord));
else
normalizedScore = BinaryDictionaryUtils.calcNormalizedScore(
final float normalizedScore = BinaryDictionaryUtils.calcNormalizedScore(
consideredWord, suggestion.mWord, autoCorrectionSuggestionScore);
if (DBG) {
Log.d(TAG, "Normalized " + consideredWord + "," + suggestion + ","
@ -75,72 +61,4 @@ public final class AutoCorrectionUtils {
}
return false;
}
// below is normalized score calculation in java, to allow unit tests involving suggestionExceedsThreshold
@UsedForTesting
private static float calcNormalizedScore(final int[] before,
final int[] after, final int score, final int distance) {
final int beforeLength = before.length;
final int afterLength = after.length;
if (0 == beforeLength || 0 == afterLength)
return 0.0f;
int spaceCount = 0;
for (int j : after) {
if (j == KEYCODE_SPACE)
++spaceCount;
}
if (spaceCount == afterLength)
return 0.0f;
if (score <= 0 || distance >= afterLength) {
// normalizedScore must be 0.0f (the minimum value) if the score is less than or equal to 0,
// or if the edit distance is larger than or equal to afterLength.
return 0.0f;
}
// add a weight based on edit distance.
final float weight = 1.0f - (float) distance / (float) afterLength;
return ((float) score / 1000000.0f) * weight;
}
@UsedForTesting
private static int editDistance(String x, String y) {
int[][] dp = new int[x.length() + 1][y.length() + 1];
for (int i = 0; i <= x.length(); i++) {
for (int j = 0; j <= y.length(); j++) {
if (i == 0) {
dp[i][j] = j;
}
else if (j == 0) {
dp[i][j] = i;
}
else {
dp[i][j] = min(dp[i - 1][j - 1]
+ costOfSubstitution(x.charAt(i - 1), y.charAt(j - 1)),
dp[i - 1][j] + 1,
dp[i][j - 1] + 1);
}
}
}
return dp[x.length()][y.length()];
}
@UsedForTesting
private static int min(int... numbers) {
int min = Integer.MAX_VALUE;
for (int n : numbers) {
if (n < min)
min = n;
}
return min;
}
@UsedForTesting
private static int costOfSubstitution(char a, char b) {
return a == b ? 0 : 1;
}
}

View file

@ -0,0 +1,375 @@
package org.dslul.openboard.inputmethod.latin
import android.inputmethodservice.InputMethodService
import android.os.Bundle
import android.os.Handler
import android.text.InputType
import android.view.KeyEvent
import android.view.inputmethod.*
import androidx.core.content.edit
import androidx.test.runner.AndroidJUnit4
import org.dslul.openboard.inputmethod.event.Event
import org.dslul.openboard.inputmethod.keyboard.KeyboardSwitcher
import org.dslul.openboard.inputmethod.keyboard.MainKeyboardView
import org.dslul.openboard.inputmethod.latin.common.StringUtils
import org.dslul.openboard.inputmethod.latin.inputlogic.InputLogic
import org.dslul.openboard.inputmethod.latin.settings.Settings
import org.dslul.openboard.inputmethod.latin.utils.DeviceProtectedUtils
import org.dslul.openboard.inputmethod.latin.utils.ScriptUtils
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.robolectric.Robolectric
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.shadows.ShadowLog
import java.util.*
@RunWith(AndroidJUnit4::class)
@Config(shadows = [
ShadowLocaleManagerCompat::class,
ShadowInputMethodManager2::class,
ShadowInputMethodService::class,
ShadowKeyboardSwitcher::class,
])
class InputLogicTest {
private lateinit var latinIME: LatinIME
// todo: test whether settings changes actually reloads and affects settingsValues
private val settingsValues get() = Settings.getInstance().current
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 beforeComposingReader = RichInputConnection::class.java.getDeclaredField("mCommittedTextBeforeComposingText").apply { isAccessible = true }
val connectionTextBeforeComposingText get() = beforeComposingReader.get(connection) as CharSequence
private val composingReader = RichInputConnection::class.java.getDeclaredField("mComposingText").apply { isAccessible = true }
val connectionComposingText get() = composingReader.get(connection) as CharSequence
@Before 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'.code)
assertEquals("c", connection.getTextBeforeCursor(10, 0).toString())
assertEquals("c", composingText)
latinIME.mHandler.onFinishInput()
assertEquals("", composingText)
println(inputLogic.mLastComposedWord.mTypedWord) // what do we expect here? currently it's empty
// connection.finishComposingText()
assertEquals("c", textBeforeCursor)
assertEquals("c", getText())
assertEquals("", textAfterCursor)
}
@Test fun insertLetterInWord() {
reset()
setText("hello")
println("typed1 "+composer.typedWord) // todo: should this be empty? check in actual app, also how it gets set
setCursorPosition(3) // after first l
println("typed2 "+composer.typedWord)
input('i'.code)
println("typed3 "+composer.typedWord)
assertEquals("helilo", getWordAtCursor())
assertEquals("helilo", getText())
assertEquals("helilo", textBeforeCursor + textAfterCursor)
assertEquals(4, getCursorPosition())
assertEquals(4, cursor)
}
// todo: why is this working here, but broken on phone?
// because the typed word is empty -> how to fix?
// this is always the case, and REALLY bad for correct mocking...
// also todo: setComposingRegion replaces composing text, that may be the (old) issue of deleting text when pressing space
@Test fun insertHangulLetterInWord() {
reset()
currentScript = ScriptUtils.SCRIPT_HANGUL
setText("ㅛㅎㄹㅎㅕㅛ")
setCursorPosition(3) // after first l
input('ㄲ'.code)
assertEquals("ㅛㅎㄹㄲㅎㅕㅛ", getWordAtCursor())
assertEquals("ㅛㅎㄹㄲㅎㅕㅛ", getText())
assertEquals("ㅛㅎㄹㄲㅎㅕㅛ", textBeforeCursor + textAfterCursor)
assertEquals(4, getCursorPosition())
assertEquals(4, cursor)
}
@Test fun setAutospace() {
println(settingsValues.mAutospaceAfterPunctuationEnabled)
setAutospaceAfterPunctuation(true)
println(settingsValues.mAutospaceAfterPunctuationEnabled)
}
// ------- helper functions ---------
// should be called before every test, so the same state is guaranteed
private fun reset() {
// reset input connection
currentScript = ScriptUtils.SCRIPT_LATIN
text = ""
selectionStart = 0
selectionEnd = 0
composingStart = 0
composingEnd = 0
batchEdit = 0
// reset settings
DeviceProtectedUtils.getSharedPreferences(latinIME).edit { clear() }
// todo: does it work if i to setText("") instead?
// plus restarting = true maybe?
// that would be the better method for setting a new text field
connection.setSelection(0, 0) // resets cache
inputLogic.restartSuggestionsOnWordTouchedByCursor(settingsValues, currentScript)
}
private fun input(codePoint: Int) {
val oldBefore = textBeforeCursor
val oldAfter = textAfterCursor
val insert = StringUtils.newSingleCodePointString(codePoint)
// essentially we replace the selected text in the input connection
// todo: what about composing text?
text = textBeforeCursor + insert + textAfterCursor
selectionStart += insert.length
selectionEnd = selectionStart
latinIME.onEvent(Event.createEventForCodePointFromUnknownSource(codePoint))
assertEquals(oldBefore + insert, textBeforeCursor)
assertEquals(oldAfter, textAfterCursor)
assertEquals(textBeforeCursor + textAfterCursor, getText())
checkConnectionConsistency()
}
// almost the same as codePoint input, but calls different latinIME function
private fun input(insert: String) {
val oldBefore = textBeforeCursor
val oldAfter = textAfterCursor
// essentially we replace the selected text in the input connection
// todo: what about composing text?
text = textBeforeCursor + insert + textAfterCursor
selectionStart += insert.length
selectionEnd = selectionStart
latinIME.onTextInput(text)
assertEquals(oldBefore + insert, textBeforeCursor)
assertEquals(oldAfter, textAfterCursor)
assertEquals(textBeforeCursor + textAfterCursor, getText())
checkConnectionConsistency()
}
private fun getWordAtCursor() = connection.getWordRangeAtCursor(
settingsValues.mSpacingAndPunctuations,
currentScript,
false
).mWord
private fun getUnderlinedWord() =
getText().substring(inputLogic.composingStart, inputLogic.composingStart + inputLogic.composingLength)
private fun setCursorPosition(start: Int, end: Int = start) {
val ei = EditorInfo()
ei.inputType = 180225 // what's this? some multi-line thing...
//ei.inputType = InputType.TYPE_CLASS_TEXT // blabla caps mode and stuff
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 + textAfterCursor
assertEquals(fullText, getText())
selectionStart = start
selectionEnd = end
assertEquals(fullText, textBeforeCursor + textAfterCursor)
// todo: any effect on composing span? check what happens
// hmm, when restarting should be true?
// todo: check! probably when switching text fields
latinIME.mHandler.onStartInput(ei, false) // essentially does nothing
latinIME.mHandler.onStartInputView(ei, false) // does the thing
assertEquals(fullText, getText()) // this may only be correct after start input?
checkConnectionConsistency()
}
// assumes we have nothing selected
private fun getCursorPosition(): Int {
assertEquals(cursor, connection.expectedSelectionStart)
assertEquals(cursor, connection.expectedSelectionEnd)
return cursor
}
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
latinIME.mHandler.onStartInput(EditorInfo(), false)
latinIME.mHandler.onStartInputView(EditorInfo(), false)
checkConnectionConsistency()
}
private fun checkConnectionConsistency() {
assertEquals(selectionStart, connection.expectedSelectionStart)
assertEquals(selectionEnd, connection.expectedSelectionEnd)
assertEquals(textBeforeComposingText, connectionTextBeforeComposingText)
assertEquals(composingText, connectionComposingText)
}
private fun getText() =
connection.getTextBeforeCursor(100, 0).toString() + connection.getTextAfterCursor(100, 0)
// nice, this really reloads the prefs!!
private fun setAutospaceAfterPunctuation(enabled: Boolean) {
DeviceProtectedUtils.getSharedPreferences(latinIME)
.edit { putBoolean(Settings.PREF_AUTOSPACE_AFTER_PUNCTUATION, enabled) }
assertEquals(enabled, settingsValues.mAutospaceAfterPunctuationEnabled)
}
}
private var currentInputType = InputType.TYPE_CLASS_TEXT
private var currentScript = ScriptUtils.SCRIPT_LATIN
// 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 textBeforeComposingText get() = if (composingStart == -1) "" else text.substring(composingStart)
private val composingText get() = if (composingStart == -1 || composingEnd == -1) ""
else text.substring(composingStart, composingEnd)
private val cursor get() = if (selectionStart == selectionEnd) selectionStart else -1
// 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, negative means relative to start
override fun setComposingText(newText: CharSequence, cursor: Int): Boolean {
// first remove the composing text if necessary
if (composingStart != -1 && composingEnd != -1)
text = textBeforeComposingText + 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
selectionStart = if (insertStart > 0) insertStart + cursor
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
}
// 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 getCursorCapsMode(p0: Int): Int = TODO("Not yet implemented")
override fun getExtractedText(p0: ExtractedTextRequest?, p1: Int): ExtractedText = TODO("Not yet implemented")
override fun deleteSurroundingText(p0: Int, p1: Int): Boolean = TODO("Not yet implemented")
override fun deleteSurroundingTextInCodePoints(p0: Int, p1: Int): Boolean = TODO("Not yet implemented")
override fun setComposingRegion(p0: Int, p1: Int): Boolean = TODO("Not yet implemented")
override fun commitCompletion(p0: CompletionInfo?): Boolean = TODO("Not yet implemented")
override fun commitCorrection(p0: CorrectionInfo?): Boolean = TODO("Not yet implemented")
override fun setSelection(p0: Int, p1: Int): 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 sendKeyEvent(p0: KeyEvent?): 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
}
@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
// only affects view
fun getCurrentKeyboardScriptId() = currentScript
}

View file

@ -0,0 +1,98 @@
package org.dslul.openboard.inputmethod.latin
import android.content.Context
import android.view.KeyEvent
import android.view.inputmethod.InputMethodInfo
import android.view.inputmethod.InputMethodManager
import android.view.inputmethod.InputMethodSubtype
import androidx.core.app.LocaleManagerCompat
import androidx.core.os.LocaleListCompat
import com.android.inputmethod.latin.utils.BinaryDictionaryUtils
import org.dslul.openboard.inputmethod.latin.common.StringUtils
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.shadows.ShadowInputMethodManager
import java.util.*
@Implements(LocaleManagerCompat::class)
object ShadowLocaleManagerCompat {
@Implementation
@JvmStatic
fun getSystemLocales(context: Context) = LocaleListCompat.create(Locale.ENGLISH)
}
// why doesn't the original ShadowInputMethodManager simply work?
@Implements(InputMethodManager::class)
class ShadowInputMethodManager2 : ShadowInputMethodManager() {
@Implementation
override fun getInputMethodList() = listOf(
InputMethodInfo("org.dslul.openboard.inputmethod.latin.debug", "LatinIME", "openboard debug", null),
)
@Implementation
fun getShortcutInputMethodsAndSubtypes() = emptyMap<InputMethodInfo, List<InputMethodSubtype>>()
}
@Implements(BinaryDictionaryUtils::class)
object ShadowBinaryDictionaryUtils {
@Implementation
@JvmStatic
fun calcNormalizedScore(beforeString: String, afterString: String, score: Int): Float {
val before = StringUtils.toCodePointArray(beforeString)
val after = StringUtils.toCodePointArray(afterString)
val distance = editDistance(beforeString, afterString)
val beforeLength = before.size
val afterLength = after.size
if (0 == beforeLength || 0 == afterLength) return 0.0f
var spaceCount = 0
for (j: Int in after) {
if (j == KeyEvent.KEYCODE_SPACE) ++spaceCount
}
if (spaceCount == afterLength) return 0.0f
if (score <= 0 || distance >= afterLength) {
// normalizedScore must be 0.0f (the minimum value) if the score is less than or equal to 0,
// or if the edit distance is larger than or equal to afterLength.
return 0.0f
}
// add a weight based on edit distance.
val weight = 1.0f - distance.toFloat() / afterLength.toFloat()
return score.toFloat() / 1000000.0f * weight
}
private fun editDistance(x: String, y: String): Int {
val dp = Array(x.length + 1) {
IntArray(
y.length + 1
)
}
for (i in 0..x.length) {
for (j in 0..y.length) {
if (i == 0) {
dp[i][j] = j
} else if (j == 0) {
dp[i][j] = i
} else {
dp[i][j] = min(
dp[i - 1][j - 1]
+ costOfSubstitution(x[i - 1], y[j - 1]),
dp[i - 1][j] + 1,
dp[i][j - 1] + 1
)
}
}
}
return dp[x.length][y.length]
}
private fun min(vararg numbers: Int): Int {
var min = Int.MAX_VALUE
for (n: Int in numbers) {
if (n < min) min = n
}
return min
}
private fun costOfSubstitution(a: Char, b: Char): Int {
return if (a == b) 0 else 1
}
}

View file

@ -1,24 +1,50 @@
package org.dslul.openboard.inputmethod.latin
import android.content.Context
import android.util.LruCache
import org.dslul.openboard.inputmethod.keyboard.Keyboard
import androidx.core.content.edit
import androidx.test.runner.AndroidJUnit4
import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo
import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo.KIND_FLAG_APPROPRIATE_FOR_AUTO_CORRECTION
import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo.KIND_WHITELIST
import org.dslul.openboard.inputmethod.latin.common.ComposedData
import org.dslul.openboard.inputmethod.latin.common.StringUtils
import org.dslul.openboard.inputmethod.latin.settings.Settings
import org.dslul.openboard.inputmethod.latin.settings.SettingsValuesForSuggestion
import org.dslul.openboard.inputmethod.latin.utils.DeviceProtectedUtils
import org.dslul.openboard.inputmethod.latin.utils.SuggestionResults
import org.junit.Before
import org.junit.Test
import java.io.File
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.annotation.Config
import org.robolectric.annotation.Implementation
import org.robolectric.annotation.Implements
import org.robolectric.shadows.ShadowLog
import java.util.*
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
@Config(shadows = [
ShadowLocaleManagerCompat::class,
ShadowInputMethodManager2::class,
ShadowBinaryDictionaryUtils::class,
ShadowFacilitator::class,
])
class SuggestTest {
private val thresholdModest = 0.185f
private val thresholdAggressive = 0.067f
private val thresholdVeryAggressive = Float.NEGATIVE_INFINITY
private lateinit var latinIME: LatinIME
private val suggest get() = latinIME.mInputLogic.mSuggest
// values taken from the string array auto_correction_threshold_mode_indexes
private val thresholdModest = "0"
private val thresholdAggressive = "1"
private val thresholdVeryAggressive = "2"
@Before 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
DeviceProtectedUtils.getSharedPreferences(latinIME)
.edit { putBoolean(Settings.PREF_AUTO_CORRECTION, true) } // need to enable, off by default
}
@Test fun `"on" to "in" if "in" was used before in this context`() {
val locale = Locale.ENGLISH
@ -197,9 +223,64 @@ class SuggestTest {
// not even allowed to check because of low score for ne
}
private fun setAutCorrectThreshold(threshold: String) {
val prefs = DeviceProtectedUtils.getSharedPreferences(latinIME)
prefs.edit { putString(Settings.PREF_AUTO_CORRECTION_CONFIDENCE, threshold) }
}
private fun shouldBeAutoCorrected(word: String, // typed word
suggestions: List<SuggestedWordInfo>, // suggestions ordered by score, including suggestion for typed word if in dictionary
firstSuggestionForEmpty: SuggestedWordInfo?, // first suggestion if typed word would be empty (null if none)
typedWordSuggestionForEmpty: SuggestedWordInfo?, // suggestion for actually typed word if typed word would be empty (null if none)
typingLocale: Locale, // used for checking whether suggestion locale is the same, relevant e.g. for English i -> I shortcut, but we want Polish i
autoCorrectThreshold: String // 0, 1, or 2, but better use the vals on top with the corresponding name
): List<Boolean> {
setAutCorrectThreshold(autoCorrectThreshold)
currentTypingLocale = typingLocale
val suggestionsContainer = ArrayList<SuggestedWordInfo>().apply { addAll(suggestions) }
val suggestionResults = SuggestionResults(suggestions.size, false, false)
suggestions.forEach { suggestionResults.add(it) }
// store the original SuggestedWordInfo for typed word, as it will be removed
// we may want to re-add it in case auto-correction happens, so that the original word can at least be selected
var typedWordFirstOccurrenceWordInfo: SuggestedWordInfo? = null
var foundInDictionary = false
var sourceDictionaryOfRemovedWord: Dictionary? = null
for (info in suggestionsContainer) {
// Search for the best dictionary, defined as the first one with the highest match
// quality we can find.
if (!foundInDictionary && word == info.mWord) {
// Use this source if the old match had lower quality than this match
sourceDictionaryOfRemovedWord = info.mSourceDict
foundInDictionary = true
typedWordFirstOccurrenceWordInfo = info
break
}
}
val firstOccurrenceOfTypedWordInSuggestions =
SuggestedWordInfo.removeDupsAndTypedWord(word, suggestionsContainer)
return suggest.shouldBeAutoCorrected(
StringUtils.getTrailingSingleQuotesCount(word),
word,
suggestionsContainer, // todo: get from suggestions? mostly it's just removing the typed word, right?
sourceDictionaryOfRemovedWord,
listOf(firstSuggestionForEmpty, typedWordSuggestionForEmpty),
{}, // only used to fill above if needed
true, // doesn't make sense otherwise
0, // not really relevant here
WordComposer.getComposerForTest(false),
suggestionResults,
firstOccurrenceOfTypedWordInSuggestions,
typedWordFirstOccurrenceWordInfo
).toList()
}
}
private fun suggestion(word: String, score: Int, locale: Locale) =
private var currentTypingLocale = Locale.ENGLISH
fun suggestion(word: String, score: Int, locale: Locale) =
SuggestedWordInfo(
/* word */ word,
/* prevWordsContext */ "", // irrelevant
@ -214,177 +295,14 @@ private fun suggestion(word: String, score: Int, locale: Locale) =
/* autoCommitFirstWordConfidence */ 0 // irrelevant?
)
fun shouldBeAutoCorrected(word: String, // typed word
suggestions: List<SuggestedWordInfo>, // suggestions ordered by score, including suggestion for typed word if in dictionary
firstSuggestionForEmpty: SuggestedWordInfo?, // first suggestion if typed word would be empty (null if none)
typedWordSuggestionForEmpty: SuggestedWordInfo?, // suggestion for actually typed word if typed word would be empty (null if none)
currentTypingLocale: Locale, // used for checking whether suggestion locale is the same, relevant e.g. for English i -> I shortcut, but we want Polish i
autoCorrectThreshold: Float // -inf, 0.067, 0.185 (for very aggressive, aggressive, modest)
): List<Boolean> {
val suggestionsContainer = ArrayList<SuggestedWordInfo>().apply { addAll(suggestions) }
val suggestionResults = SuggestionResults(suggestions.size, false, false)
suggestions.forEach { suggestionResults.add(it) }
// store the original SuggestedWordInfo for typed word, as it will be removed
// we may want to re-add it in case auto-correction happens, so that the original word can at least be selected
var typedWordFirstOccurrenceWordInfo: SuggestedWordInfo? = null
var foundInDictionary = false
var sourceDictionaryOfRemovedWord: Dictionary? = null
for (info in suggestionsContainer) {
// Search for the best dictionary, defined as the first one with the highest match
// quality we can find.
if (!foundInDictionary && word == info.mWord) {
// Use this source if the old match had lower quality than this match
sourceDictionaryOfRemovedWord = info.mSourceDict
foundInDictionary = true
typedWordFirstOccurrenceWordInfo = info
break
}
}
val firstOccurrenceOfTypedWordInSuggestions =
SuggestedWordInfo.removeDupsAndTypedWord(word, suggestionsContainer)
return Suggest.shouldBeAutoCorrected(
StringUtils.getTrailingSingleQuotesCount(word),
word,
suggestionsContainer, // todo: get from suggestions? mostly it's just removing the typed word, right?
sourceDictionaryOfRemovedWord,
listOf(firstSuggestionForEmpty, typedWordSuggestionForEmpty),
{}, // only used to fill above if needed
true, // doesn't make sense otherwise
0, // not really relevant here
WordComposer.getComposerForTest(false),
suggestionResults,
facilitator(currentTypingLocale),
autoCorrectThreshold,
firstOccurrenceOfTypedWordInSuggestions,
typedWordFirstOccurrenceWordInfo
).toList()
@Implements(DictionaryFacilitatorImpl::class)
class ShadowFacilitator {
@Implementation
fun getCurrentLocale() = currentTypingLocale
@Implementation
fun hasAtLeastOneInitializedMainDictionary() = true // otherwise no autocorrect
}
private fun facilitator(currentTypingLocale: Locale): DictionaryFacilitator =
object : DictionaryFacilitator {
override fun setValidSpellingWordReadCache(cache: LruCache<String, Boolean>?) {
TODO("Not yet implemented")
}
override fun setValidSpellingWordWriteCache(cache: LruCache<String, Boolean>?) {
TODO("Not yet implemented")
}
override fun isForLocale(locale: Locale?): Boolean {
TODO("Not yet implemented")
}
override fun isForAccount(account: String?): Boolean {
TODO("Not yet implemented")
}
override fun onStartInput() {
TODO("Not yet implemented")
}
override fun onFinishInput(context: Context?) {
TODO("Not yet implemented")
}
override fun isActive(): Boolean {
TODO("Not yet implemented")
}
override fun getLocale(): Locale {
TODO("Not yet implemented")
}
override fun getCurrentLocale(): Locale = currentTypingLocale
override fun usesContacts(): Boolean {
TODO("Not yet implemented")
}
override fun getAccount(): String {
TODO("Not yet implemented")
}
override fun resetDictionaries(
context: Context?,
newLocale: Locale?,
useContactsDict: Boolean,
usePersonalizedDicts: Boolean,
forceReloadMainDictionary: Boolean,
account: String?,
dictNamePrefix: String?,
listener: DictionaryFacilitator.DictionaryInitializationListener?
) {
TODO("Not yet implemented")
}
override fun removeWord(word: String?) {
TODO("Not yet implemented")
}
override fun resetDictionariesForTesting(
context: Context?,
locale: Locale?,
dictionaryTypes: java.util.ArrayList<String>?,
dictionaryFiles: HashMap<String, File>?,
additionalDictAttributes: MutableMap<String, MutableMap<String, String>>?,
account: String?
) {
TODO("Not yet implemented")
}
override fun closeDictionaries() {
TODO("Not yet implemented")
}
override fun getSubDictForTesting(dictName: String?): ExpandableBinaryDictionary {
TODO("Not yet implemented")
}
override fun hasAtLeastOneInitializedMainDictionary(): Boolean = true
override fun hasAtLeastOneUninitializedMainDictionary(): Boolean {
TODO("Not yet implemented")
}
override fun waitForLoadingMainDictionaries(timeout: Long, unit: TimeUnit?) {
TODO("Not yet implemented")
}
override fun waitForLoadingDictionariesForTesting(timeout: Long, unit: TimeUnit?) {
TODO("Not yet implemented")
}
override fun addToUserHistory(
suggestion: String?,
wasAutoCapitalized: Boolean,
ngramContext: NgramContext,
timeStampInSeconds: Long,
blockPotentiallyOffensive: Boolean
) {
TODO("Not yet implemented")
}
override fun unlearnFromUserHistory(
word: String?,
ngramContext: NgramContext,
timeStampInSeconds: Long,
eventType: Int
) {
TODO("Not yet implemented")
}
override fun getSuggestionResults(
composedData: ComposedData?,
ngramContext: NgramContext?,
keyboard: Keyboard,
settingsValuesForSuggestion: SettingsValuesForSuggestion?,
sessionId: Int,
inputStyle: Int
): SuggestionResults {
TODO("Not yet implemented")
}
override fun isValidSpellingWord(word: String?): Boolean {
TODO("Not yet implemented")
}
override fun isValidSuggestionWord(word: String?): Boolean {
TODO("Not yet implemented")
}
override fun clearUserHistoryDictionary(context: Context?): Boolean {
TODO("Not yet implemented")
}
override fun dump(context: Context?): String {
TODO("Not yet implemented")
}
override fun dumpDictionaryForDebug(dictName: String?) {
TODO("Not yet implemented")
}
override fun getDictionaryStats(context: Context?): MutableList<DictionaryStats> {
TODO("Not yet implemented")
}
}
private class TestDict(locale: Locale) : Dictionary("testDict", locale) {
override fun getSuggestions(
composedData: ComposedData?,