From 70f419efe51d349a1fad1921500bf7641488c176 Mon Sep 17 00:00:00 2001 From: Helium314 Date: Tue, 19 Sep 2023 19:39:47 +0200 Subject: [PATCH] add unit tests for input logic (wip very much) --- app/build.gradle | 12 + .../dslul/openboard/inputmethod/latin/App.kt | 3 +- .../openboard/inputmethod/latin/Suggest.java | 14 +- .../latin/utils/AutoCorrectionUtils.java | 84 +--- .../inputmethod/latin/InputLogicTest.kt | 375 ++++++++++++++++++ .../openboard/inputmethod/latin/Shadows.kt | 98 +++++ .../inputmethod/latin/SuggestTest.kt | 274 +++++-------- 7 files changed, 590 insertions(+), 270 deletions(-) create mode 100644 app/src/test/java/org/dslul/openboard/inputmethod/latin/InputLogicTest.kt create mode 100644 app/src/test/java/org/dslul/openboard/inputmethod/latin/Shadows.kt diff --git a/app/build.gradle b/app/build.gradle index 3e07e121c..f72ccb5e7 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -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' } diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/App.kt b/app/src/main/java/org/dslul/openboard/inputmethod/latin/App.kt index 539b6bc24..6bdcf8824 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/App.kt +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/App.kt @@ -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 diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/Suggest.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/Suggest.java index 379036b11..85023de0e 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/Suggest.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/Suggest.java @@ -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 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 diff --git a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/AutoCorrectionUtils.java b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/AutoCorrectionUtils.java index d92cfded7..180cde1ab 100644 --- a/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/AutoCorrectionUtils.java +++ b/app/src/main/java/org/dslul/openboard/inputmethod/latin/utils/AutoCorrectionUtils.java @@ -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; - } } diff --git a/app/src/test/java/org/dslul/openboard/inputmethod/latin/InputLogicTest.kt b/app/src/test/java/org/dslul/openboard/inputmethod/latin/InputLogicTest.kt new file mode 100644 index 000000000..977e5efac --- /dev/null +++ b/app/src/test/java/org/dslul/openboard/inputmethod/latin/InputLogicTest.kt @@ -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 +} diff --git a/app/src/test/java/org/dslul/openboard/inputmethod/latin/Shadows.kt b/app/src/test/java/org/dslul/openboard/inputmethod/latin/Shadows.kt new file mode 100644 index 000000000..b98e0a764 --- /dev/null +++ b/app/src/test/java/org/dslul/openboard/inputmethod/latin/Shadows.kt @@ -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>() +} + +@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 + } + +} diff --git a/app/src/test/java/org/dslul/openboard/inputmethod/latin/SuggestTest.kt b/app/src/test/java/org/dslul/openboard/inputmethod/latin/SuggestTest.kt index ef690cfda..c0d6c1fce 100644 --- a/app/src/test/java/org/dslul/openboard/inputmethod/latin/SuggestTest.kt +++ b/app/src/test/java/org/dslul/openboard/inputmethod/latin/SuggestTest.kt @@ -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, // 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 { + setAutCorrectThreshold(autoCorrectThreshold) + currentTypingLocale = typingLocale + val suggestionsContainer = ArrayList().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, // 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 { - val suggestionsContainer = ArrayList().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?) { - TODO("Not yet implemented") - } - override fun setValidSpellingWordWriteCache(cache: LruCache?) { - 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?, - dictionaryFiles: HashMap?, - additionalDictAttributes: MutableMap>?, - 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 { - TODO("Not yet implemented") - } - - } - private class TestDict(locale: Locale) : Dictionary("testDict", locale) { override fun getSuggestions( composedData: ComposedData?,