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 useLegacyPackaging true
} }
} }
testOptions {
unitTests {
includeAndroidResources = true
}
}
} }
dependencies { dependencies {
@ -64,5 +70,11 @@ dependencies {
implementation 'androidx.recyclerview:recyclerview:1.3.1' implementation 'androidx.recyclerview:recyclerview:1.3.1'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.github.skydoves:colorpickerview:2.2.4' implementation 'com.github.skydoves:colorpickerview:2.2.4'
testImplementation 'junit:junit:4.13.2' 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 { fun install(): Boolean {
val ueh = Thread.getDefaultUncaughtExceptionHandler() val ueh = Thread.getDefaultUncaughtExceptionHandler()
check(ueh !is CrashReportExceptionHandler) { "May not install several CrashReportExceptionHandlers!" } if (ueh is CrashReportExceptionHandler)
return false
defaultUncaughtExceptionHandler = ueh defaultUncaughtExceptionHandler = ueh
Thread.setDefaultUncaughtExceptionHandler(this) Thread.setDefaultUncaughtExceptionHandler(this)
return true return true

View file

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

View file

@ -16,18 +16,11 @@
package org.dslul.openboard.inputmethod.latin.utils; 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 android.util.Log;
import com.android.inputmethod.latin.utils.BinaryDictionaryUtils; 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.SuggestedWords.SuggestedWordInfo;
import org.dslul.openboard.inputmethod.latin.common.StringUtils;
import org.dslul.openboard.inputmethod.latin.define.DebugFlags; import org.dslul.openboard.inputmethod.latin.define.DebugFlags;
public final class AutoCorrectionUtils { public final class AutoCorrectionUtils {
@ -38,7 +31,6 @@ public final class AutoCorrectionUtils {
// Purely static class: can't instantiate. // Purely static class: can't instantiate.
} }
@SuppressLint("ObsoleteSdkInt") // SDK_INT is 0 in unit tests
public static boolean suggestionExceedsThreshold(final SuggestedWordInfo suggestion, public static boolean suggestionExceedsThreshold(final SuggestedWordInfo suggestion,
final String consideredWord, final float threshold) { final String consideredWord, final float threshold) {
if (null != suggestion) { if (null != suggestion) {
@ -53,13 +45,7 @@ public final class AutoCorrectionUtils {
final int autoCorrectionSuggestionScore = suggestion.mScore; final int autoCorrectionSuggestionScore = suggestion.mScore;
// TODO: when the normalized score of the first suggestion is nearly equals to // TODO: when the normalized score of the first suggestion is nearly equals to
// the normalized score of the second suggestion, behave less aggressive. // the normalized score of the second suggestion, behave less aggressive.
final float normalizedScore; final float normalizedScore = BinaryDictionaryUtils.calcNormalizedScore(
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(
consideredWord, suggestion.mWord, autoCorrectionSuggestionScore); consideredWord, suggestion.mWord, autoCorrectionSuggestionScore);
if (DBG) { if (DBG) {
Log.d(TAG, "Normalized " + consideredWord + "," + suggestion + "," Log.d(TAG, "Normalized " + consideredWord + "," + suggestion + ","
@ -75,72 +61,4 @@ public final class AutoCorrectionUtils {
} }
return false; 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 package org.dslul.openboard.inputmethod.latin
import android.content.Context import androidx.core.content.edit
import android.util.LruCache import androidx.test.runner.AndroidJUnit4
import org.dslul.openboard.inputmethod.keyboard.Keyboard
import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo 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_FLAG_APPROPRIATE_FOR_AUTO_CORRECTION
import org.dslul.openboard.inputmethod.latin.SuggestedWords.SuggestedWordInfo.KIND_WHITELIST 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.ComposedData
import org.dslul.openboard.inputmethod.latin.common.StringUtils 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.settings.SettingsValuesForSuggestion
import org.dslul.openboard.inputmethod.latin.utils.DeviceProtectedUtils
import org.dslul.openboard.inputmethod.latin.utils.SuggestionResults import org.dslul.openboard.inputmethod.latin.utils.SuggestionResults
import org.junit.Before
import org.junit.Test 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.*
import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
@Config(shadows = [
ShadowLocaleManagerCompat::class,
ShadowInputMethodManager2::class,
ShadowBinaryDictionaryUtils::class,
ShadowFacilitator::class,
])
class SuggestTest { class SuggestTest {
private val thresholdModest = 0.185f private lateinit var latinIME: LatinIME
private val thresholdAggressive = 0.067f private val suggest get() = latinIME.mInputLogic.mSuggest
private val thresholdVeryAggressive = Float.NEGATIVE_INFINITY
// 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`() { @Test fun `"on" to "in" if "in" was used before in this context`() {
val locale = Locale.ENGLISH val locale = Locale.ENGLISH
@ -197,9 +223,64 @@ class SuggestTest {
// not even allowed to check because of low score for ne // 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( SuggestedWordInfo(
/* word */ word, /* word */ word,
/* prevWordsContext */ "", // irrelevant /* prevWordsContext */ "", // irrelevant
@ -214,177 +295,14 @@ private fun suggestion(word: String, score: Int, locale: Locale) =
/* autoCommitFirstWordConfidence */ 0 // irrelevant? /* autoCommitFirstWordConfidence */ 0 // irrelevant?
) )
fun shouldBeAutoCorrected(word: String, // typed word @Implements(DictionaryFacilitatorImpl::class)
suggestions: List<SuggestedWordInfo>, // suggestions ordered by score, including suggestion for typed word if in dictionary class ShadowFacilitator {
firstSuggestionForEmpty: SuggestedWordInfo?, // first suggestion if typed word would be empty (null if none) @Implementation
typedWordSuggestionForEmpty: SuggestedWordInfo?, // suggestion for actually typed word if typed word would be empty (null if none) fun getCurrentLocale() = currentTypingLocale
currentTypingLocale: Locale, // used for checking whether suggestion locale is the same, relevant e.g. for English i -> I shortcut, but we want Polish i @Implementation
autoCorrectThreshold: Float // -inf, 0.067, 0.185 (for very aggressive, aggressive, modest) fun hasAtLeastOneInitializedMainDictionary() = true // otherwise no autocorrect
): 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()
} }
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) { private class TestDict(locale: Locale) : Dictionary("testDict", locale) {
override fun getSuggestions( override fun getSuggestions(
composedData: ComposedData?, composedData: ComposedData?,