diff --git a/app/src/keyboards/java/be/scri/helpers/CommandHandler.kt b/app/src/keyboards/java/be/scri/helpers/CommandHandler.kt index 9c09f0a5..15cb478b 100644 --- a/app/src/keyboards/java/be/scri/helpers/CommandHandler.kt +++ b/app/src/keyboards/java/be/scri/helpers/CommandHandler.kt @@ -115,7 +115,7 @@ class CommandHandler( if (tempOutput?.isEmpty() == true || tempOutput?.values?.all { it.isEmpty() } == true) { null } else if ((isAllCaps || isCapitalized) && tempOutput != null) { - ime.applyCapitalizationToConjugations(tempOutput, isAllCaps) + ConjugationHandler.applyCapitalizationToConjugations(tempOutput, isAllCaps) } else { tempOutput } diff --git a/app/src/keyboards/java/be/scri/helpers/ConjugationHandler.kt b/app/src/keyboards/java/be/scri/helpers/ConjugationHandler.kt new file mode 100644 index 00000000..19b2ed73 --- /dev/null +++ b/app/src/keyboards/java/be/scri/helpers/ConjugationHandler.kt @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers + +import android.content.Context.MODE_PRIVATE +import android.view.View +import androidx.core.content.edit +import be.scri.R +import be.scri.models.ScribeState + +private const val DATA_SIZE_2 = 2 +private const val DATA_SIZE_3 = 3 + +/** + * Encapsulates verb conjugation state management, conjugation table layout logic, + * and capitalization formatting for GeneralKeyboardIME. + */ +class ConjugationHandler( + private val ime: KeyboardIMEContext, +) { + var subsequentAreaRequired: Boolean = false + private set + var subsequentData: MutableList> = mutableListOf() + + /** + * Saves the type of conjugation layout being used (e.g., "2x2", "none") to shared preferences. + * + * For the **primary conjugation view** (isSubsequent = false): + * - If [language] is `"none"` (idle/reset sentinel), saves `"none"` directly. + * - Otherwise uses [KeyboardIMEContext.defaultConjugateModeType], so each IME subclass + * controls its own mode without a hardcoded language list here. + * + * For a **sub-view** (isSubsequent = true), saves [subViewMode] (e.g. "2x1" or "1x3") + * so that [be.scri.helpers.KeyboardBase] picks the correct row height. + * + * @param language The current keyboard language, or "none" to reset to the idle mode. + * @param isSubsequent true if saving for a sub-view, false for the standard conjugation view. + * @param subViewMode The layout mode string for the sub-view. Only used when [isSubsequent] is true. + */ + fun saveConjugateModeType( + language: String, + isSubsequent: Boolean = false, + subViewMode: String = "none", + ) { + val mode = + when { + isSubsequent -> subViewMode + language == "none" -> "none" + else -> ime.defaultConjugateModeType + } + val sharedPref = ime.imeContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) + sharedPref.edit { putString("conjugate_mode_type", mode) } + } + + /** + * Handles a key press on one of the special conjugation keys. + * It either commits the text directly or prepares for a subsequent selection view. + * + * @param code The key code of the pressed key. + * @param isSubsequentRequired true if a sub-view is needed for more options. + * + * @return The label of the key that was pressed. + */ + fun handleConjugateKeys( + code: Int, + isSubsequentRequired: Boolean, + ): String? { + val keyLabel = ime.keyboardView?.getKeyLabel(code) + if (!isSubsequentRequired) { + if (!keyLabel.isNullOrEmpty()) { + ime.getInputConnection()?.commitText("$keyLabel ", 1) + ime.suggestionHandler.processLinguisticSuggestions(keyLabel) + } + } + return keyLabel + } + + /** + * Sets up a secondary "sub-view" for conjugation when a single key has multiple options. + * + * @param data The full dataset of subsequent options. + * @param word The specific word selected from the primary view, used to filter the data. + */ + fun setupConjugateSubView( + data: List>, + word: String?, + ) { + val uniqueData = data.distinct() + val filteredData = uniqueData.filter { sublist -> sublist.contains(word) } + val flattenList = filteredData.flatten() + val keyboardXmlId = getKeyboardLayoutForState(ime.currentState, dataSize = flattenList.size) + val subViewMode = + when (flattenList.size) { + DATA_SIZE_2 -> "2x1" + DATA_SIZE_3 -> "1x3" + else -> return + } + saveConjugateModeType(language = ime.language, isSubsequent = true, subViewMode = subViewMode) + ime.uiManager.initializeKeyboard(keyboardXmlId) + when (flattenList.size) { + DATA_SIZE_2 -> { + ime.keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_2X1_TOP) + ime.keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_2X1_BOTTOM) + subsequentAreaRequired = false + } + + DATA_SIZE_3 -> { + ime.keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_1X3_LEFT) + ime.keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_1X3_CENTER) + ime.keyboardView?.setKeyLabel(flattenList[DATA_SIZE_2], "HI", KeyboardBase.CODE_1X3_RIGHT) + subsequentAreaRequired = false + } + } + ime.uiManager.binding.ivInfo.visibility = View.GONE + } + + /** + * Determines which keyboard layout XML to use for a conjugation sub-view based on the number + * of items in the sub-view. Only called from [setupConjugateSubView] for sizes 2 and 3; + * other sizes trigger an early return before this is used. + * + * @param state The current state of the Scribe keyboard. + * @param dataSize The number of items to display, used to select an appropriate layout. + * + * @return The resource ID of the keyboard layout XML. + */ + private fun getKeyboardLayoutForState( + state: ScribeState, + dataSize: Int, + ): Int = + when (state) { + ScribeState.SELECT_VERB_CONJUNCTION -> { + when (dataSize) { + DATA_SIZE_2 -> R.xml.conjugate_view_2x1 + DATA_SIZE_3 -> R.xml.conjugate_view_1x3 + else -> ime.defaultConjugateLayoutXML + } + } + + else -> { + ime.getKeyboardLayoutXML() + } + } + + companion object { + /** + * Applies capitalization to all conjugated forms in the output map. + * Supports both standard capitalization (first letter) and all capital letters formatting. + * + * @param conjugations The original map of conjugations from the database. + * @param isAllCaps If true, applies all capital letters; if false, capitalizes only first letter. + * + * @return A new map with properly formatted conjugations. + */ + fun applyCapitalizationToConjugations( + conjugations: MutableMap>>, + isAllCaps: Boolean = false, + ): MutableMap>> { + val formattedOutput: MutableMap>> = mutableMapOf() + conjugations.forEach { (tenseKey, conjugationMap) -> + val formattedConjugations: MutableMap> = mutableMapOf() + conjugationMap.forEach { (categoryKey, forms) -> + val formattedForms = + forms.map { form -> + when { + form.isEmpty() -> form + isAllCaps -> form.uppercase() + else -> form.replaceFirstChar { it.uppercase() } + } + } + formattedConjugations[categoryKey] = formattedForms + } + formattedOutput[tenseKey] = formattedConjugations + } + return formattedOutput + } + } +} diff --git a/app/src/keyboards/java/be/scri/helpers/KeyboardIMEContext.kt b/app/src/keyboards/java/be/scri/helpers/KeyboardIMEContext.kt index 184fb238..31c97cc4 100644 --- a/app/src/keyboards/java/be/scri/helpers/KeyboardIMEContext.kt +++ b/app/src/keyboards/java/be/scri/helpers/KeyboardIMEContext.kt @@ -73,6 +73,11 @@ interface KeyboardIMEContext { val caseAnnotation: HashMap> val suggestionWords: HashMap> val emojiKeywords: HashMap>? + var conjugateOutput: MutableMap>>? + val defaultConjugateLayoutXML: Int + val defaultConjugateModeType: String + + fun getKeyboardLayoutXML(): Int fun handleDelete(isLongPress: Boolean = false) diff --git a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt b/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt index 7a78353d..ff0656b5 100644 --- a/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt +++ b/app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt @@ -673,7 +673,7 @@ class KeyboardUIManager( * Retrieves and validates the stored index for the current conjugation view. * Ensures the index is within the bounds of available conjugation types. */ - private fun getValidatedConjugateIndex(conjugateOutput: Map?): Int { + internal fun getValidatedConjugateIndex(conjugateOutput: Map?): Int { val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) var index = prefs.getInt("conjugate_index", 0) val maxIndex = conjugateOutput?.keys?.count()?.minus(1) ?: -1 diff --git a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt index 3a75144e..7aa40cec 100644 --- a/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt +++ b/app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt @@ -22,13 +22,13 @@ import android.view.inputmethod.EditorInfo.IME_MASK_ACTION import android.view.inputmethod.ExtractedTextRequest import android.view.inputmethod.InputConnection import android.widget.TextView -import androidx.core.content.edit import be.scri.R import be.scri.activities.MainActivity import be.scri.databinding.InputMethodViewBinding import be.scri.helpers.AutocompletionHandler import be.scri.helpers.BackspaceHandler import be.scri.helpers.CommandHandler +import be.scri.helpers.ConjugationHandler import be.scri.helpers.DatabaseManagers import be.scri.helpers.EmojiUtils.insertEmoji import be.scri.helpers.FloatingKeyboardHandler @@ -61,9 +61,6 @@ import be.scri.models.ScribeState import be.scri.views.KeyboardView import java.util.Locale -private const val DATA_SIZE_2 = 2 -private const val DATA_CONSTANT_3 = 3 - @Suppress("TooManyFunctions", "LargeClass") abstract class GeneralKeyboardIME( override val scribeLanguage: ScribeLanguage, @@ -106,8 +103,8 @@ abstract class GeneralKeyboardIME( abstract var switchToLetters: Boolean // Language-specific layout and behavior configurations (decoupled from base class). - open val defaultConjugateModeType: String = "3x2" - open val defaultConjugateLayoutXML: Int = R.xml.conjugate_view_3x2 + open override val defaultConjugateModeType: String = "2x2" + override val defaultConjugateLayoutXML: Int = R.xml.conjugate_view_3x2 open val isPluralCapitalized: Boolean = false /** @@ -146,8 +143,7 @@ abstract class GeneralKeyboardIME( // MARK: State Variables override var isSingularAndPlural: Boolean = false - private var subsequentAreaRequired: Boolean = false - private var subsequentData: MutableList> = mutableListOf() + internal val conjugationHandler by lazy { ConjugationHandler(this) } private val shiftPermToggleSpeed: Int = DEFAULT_SHIFT_PERM_TOGGLE_SPEED @@ -181,7 +177,7 @@ abstract class GeneralKeyboardIME( dataHandler.emojiKeywords = value } - internal var conjugateOutput: MutableMap>>? + override var conjugateOutput: MutableMap>>? get() = dataHandler.conjugateOutput set(value) { dataHandler.conjugateOutput = value @@ -656,20 +652,7 @@ abstract class GeneralKeyboardIME( override fun saveConjugateModeType( language: String, isSubsequentArea: Boolean, - ) { - val sharedPref = applicationContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) - val mode = - if (!isSubsequentArea) { - when (language) { - "English", "Russian", "Swedish" -> "2x2" - "German", "French", "Italian", "Portuguese", "Spanish" -> "2x2" - else -> "none" - } - } else { - "none" - } - sharedPref.edit { putString("conjugate_mode_type", mode) } - } + ) = conjugationHandler.saveConjugateModeType(language, isSubsequentArea) // MARK: UI Update Delegation @@ -806,7 +789,7 @@ abstract class GeneralKeyboardIME( override fun commitText(text: String) { if (currentState == ScribeState.SELECT_VERB_CONJUNCTION) { val label = text.trim() - val conjugateIndex = getValidatedConjugateIndex() + val conjugateIndex = uiManager.getValidatedConjugateIndex(conjugateOutput) val title = conjugateOutput?.keys?.elementAtOrNull(conjugateIndex) val languageOutput = title?.let { conjugateOutput!![it] } @@ -1062,47 +1045,6 @@ abstract class GeneralKeyboardIME( commandBarInput: String, ): String = dataHandler.getTranslation(language, commandBarInput) - /** - * @return A new map with properly formatted conjugations. - */ - internal fun applyCapitalizationToConjugations( - conjugations: MutableMap>>, - isAllCaps: Boolean = false, - ): MutableMap>> { - val formattedOutput: MutableMap>> = mutableMapOf() - conjugations.forEach { (tenseKey, conjugationMap) -> - val formattedConjugations: MutableMap> = mutableMapOf() - conjugationMap.forEach { (categoryKey, forms) -> - val formattedForms = - forms.map { form -> - when { - form.isEmpty() -> form - isAllCaps -> form.uppercase() - else -> form.replaceFirstChar { it.uppercase() } - } - } - formattedConjugations[categoryKey] = formattedForms - } - formattedOutput[tenseKey] = formattedConjugations - } - return formattedOutput - } - - /** - * Retrieves and validates the stored index for the current conjugation view. - * Ensures the index is within the bounds of available conjugation types. - * - * @return A valid, zero-based index for the conjugation type. - */ - private fun getValidatedConjugateIndex(): Int { - val prefs = getSharedPreferences("keyboard_preferences", MODE_PRIVATE) - var index = prefs.getInt("conjugate_index", 0) - val maxIndex = conjugateOutput?.keys?.count()?.minus(1) ?: -1 - index = if (maxIndex >= 0) index.coerceIn(0, maxIndex) else 0 - prefs.edit { putInt("conjugate_index", index) } - return index - } - /** * Handles the logic for the Shift key. It cycles through shift states (off, on-for-one-char, caps lock) * on the letter keyboard, and toggles between symbol pages on the symbol keyboard. @@ -1298,108 +1240,33 @@ abstract class GeneralKeyboardIME( /** * Returns whether the current conjugation state requires a subsequent selection view. - * This is used, for example, when a conjugation form has multiple options (e.g., "am/is/are" in English). - * - * @return true if a subsequent selection screen is needed, false otherwise. + * Delegated to [ConjugationHandler]. */ - override fun returnIsSubsequentRequired(): Boolean = subsequentAreaRequired - - override fun returnSubsequentData(): List> = subsequentData + override fun returnIsSubsequentRequired(): Boolean = conjugationHandler.subsequentAreaRequired /** * Handles a key press on one of the special conjugation keys. - * It either commits the text directly or prepares for a subsequent selection view. - * - * @param code The key code of the pressed key. - * @param isSubsequentRequired true if a sub-view is needed for more options. - * - * @return The label of the key that was pressed. + * Delegated to [ConjugationHandler]. */ override fun handleConjugateKeys( code: Int, isSubsequentRequired: Boolean, - ): String? { - val keyLabel = keyboardView?.getKeyLabel(code) - if (!isSubsequentRequired) { - if (!keyLabel.isNullOrEmpty()) { - currentInputConnection?.commitText("$keyLabel ", 1) - suggestionHandler.processLinguisticSuggestions(keyLabel) - } - } - return keyLabel - } + ): String? = conjugationHandler.handleConjugateKeys(code, isSubsequentRequired) /** * Sets up a secondary "sub-view" for conjugation when a single key has multiple options. - * - * @param data The full dataset of subsequent options. - * @param word The specific word selected from the primary view, used to filter the data. + * Delegated to [ConjugationHandler]. */ override fun setupConjugateSubView( data: List>, word: String?, - ) { - val uniqueData = data.distinct() - val filteredData = uniqueData.filter { sublist -> sublist.contains(word) } - val flattenList = filteredData.flatten() - saveConjugateModeType(language = language, true) - val prefs = applicationContext.getSharedPreferences("keyboard_preferences", MODE_PRIVATE) - prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } - val keyboardXmlId = getKeyboardLayoutForState(currentState, true, flattenList.size) - // Re-initialize keyboard via UI manager helper which calls 'initializeKeyboard(xml)'. - uiManager.initializeKeyboard(keyboardXmlId) - prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } - when (flattenList.size) { - DATA_SIZE_2 -> { - keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_2X1_TOP) - keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_2X1_BOTTOM) - subsequentAreaRequired = false - } - - DATA_CONSTANT_3 -> { - keyboardView?.setKeyLabel(flattenList[0], "HI", KeyboardBase.CODE_1X3_RIGHT) - keyboardView?.setKeyLabel(flattenList[1], "HI", KeyboardBase.CODE_1X3_CENTER) - keyboardView?.setKeyLabel(flattenList[DATA_SIZE_2], "HI", KeyboardBase.CODE_1X3_RIGHT) - subsequentAreaRequired = false - } - } - prefs.edit(commit = true) { putString("conjugate_mode_type", "2x1") } - // Binding access via uiManager. - uiManager.binding.ivInfo.visibility = View.GONE - } + ) = conjugationHandler.setupConjugateSubView(data, word) /** - * Determines which keyboard layout XML to use based on the current [ScribeState]. - * - * @param state The current state of the Scribe keyboard. - * @param isSubsequentArea true if this is for a secondary conjugation view. - * @param dataSize The number of items to display, used to select an appropriate layout. - * - * @return The resource ID of the keyboard layout XML. + * Returns the subsequent dataset for conjugation sub-views. + * Delegated to [ConjugationHandler]. */ - private fun getKeyboardLayoutForState( - state: ScribeState, - isSubsequentArea: Boolean = false, - dataSize: Int = 0, - ): Int = - when (state) { - ScribeState.SELECT_VERB_CONJUNCTION -> { - saveConjugateModeType(language) - if (!isSubsequentArea && dataSize == 0) { - defaultConjugateLayoutXML - } else { - when (dataSize) { - DATA_SIZE_2 -> R.xml.conjugate_view_2x1 - DATA_CONSTANT_3 -> R.xml.conjugate_view_1x3 - else -> R.xml.conjugate_view_2x2 - } - } - } - - else -> { - getKeyboardLayoutXML() - } - } + override fun returnSubsequentData(): List> = conjugationHandler.subsequentData /** * Updates the visibility of the suggestion buttons based on device type (phone/tablet) diff --git a/app/src/main/java/be/scri/helpers/KeyboardBase.kt b/app/src/main/java/be/scri/helpers/KeyboardBase.kt index 9704e93f..354368f7 100644 --- a/app/src/main/java/be/scri/helpers/KeyboardBase.kt +++ b/app/src/main/java/be/scri/helpers/KeyboardBase.kt @@ -217,7 +217,7 @@ class KeyboardBase { when (conjugateMode) { "2x2" -> res.getDimension(R.dimen.conjugate_view_key_height_2x2).toInt() "3x3" -> res.getDimension(R.dimen.conjugate_view_key_height_3x3).toInt() - "2x1" -> res.getDimension(R.dimen.conjugate_view_key_height_2x1).toInt() + "2x1", "1x3" -> res.getDimension(R.dimen.conjugate_view_key_height_2x1).toInt() else -> res.getDimension(R.dimen.conjugate_view_key_height_3x3).toInt() } } else { diff --git a/app/src/testKeyboards/kotlin/be/scri/helpers/ConjugationHandlerTest.kt b/app/src/testKeyboards/kotlin/be/scri/helpers/ConjugationHandlerTest.kt new file mode 100644 index 00000000..2e1ae5b2 --- /dev/null +++ b/app/src/testKeyboards/kotlin/be/scri/helpers/ConjugationHandlerTest.kt @@ -0,0 +1,224 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package be.scri.helpers + +import android.content.Context +import android.view.LayoutInflater +import android.view.View +import android.view.inputmethod.InputConnection +import androidx.test.core.app.ApplicationProvider +import be.scri.R +import be.scri.databinding.InputMethodViewBinding +import be.scri.helpers.ui.KeyboardUIManager +import be.scri.models.ScribeState +import be.scri.views.KeyboardView +import io.mockk.every +import io.mockk.mockk +import io.mockk.unmockkAll +import io.mockk.verify +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class ConjugationHandlerTest { + private lateinit var context: Context + private lateinit var binding: InputMethodViewBinding + private val ime = mockk(relaxed = true) + private val uiManager = mockk(relaxed = true) + private val keyboardView = mockk(relaxed = true) + private val inputConnection = mockk(relaxed = true) + private val suggestionHandler = mockk(relaxed = true) + + private lateinit var handler: ConjugationHandler + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + binding = InputMethodViewBinding.inflate(LayoutInflater.from(context)) + + every { ime.imeContext } returns context + every { ime.keyboardView } returns keyboardView + every { ime.getInputConnection() } returns inputConnection + every { ime.suggestionHandler } returns suggestionHandler + every { ime.uiManager } returns uiManager + every { uiManager.binding } returns binding + + handler = ConjugationHandler(ime) + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun applyCapitalizationToConjugations_firstLetterCapitalization() { + val input: MutableMap>> = + mutableMapOf( + "Present" to mutableMapOf("I" to listOf("go", "run", "")), + ) + + val result = ConjugationHandler.applyCapitalizationToConjugations(input, isAllCaps = false) + + assertEquals(listOf("Go", "Run", ""), result["Present"]?.get("I")) + } + + @Test + fun applyCapitalizationToConjugations_allCapsFormat() { + val input: MutableMap>> = + mutableMapOf( + "Present" to mutableMapOf("I" to listOf("go", "run", "")), + ) + + val result = ConjugationHandler.applyCapitalizationToConjugations(input, isAllCaps = true) + + assertEquals(listOf("GO", "RUN", ""), result["Present"]?.get("I")) + } + + @Test + fun saveConjugateModeType_usesDefaultConjugateModeTypeFromIme() { + every { ime.defaultConjugateModeType } returns "2x2" + + handler.saveConjugateModeType("Spanish", isSubsequent = false) + + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + assertEquals("2x2", prefs.getString("conjugate_mode_type", null)) + } + + @Test + fun saveConjugateModeType_3x2ImeReturns3x2() { + every { ime.defaultConjugateModeType } returns "3x2" + + handler.saveConjugateModeType("French", isSubsequent = false) + + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + assertEquals("3x2", prefs.getString("conjugate_mode_type", null)) + } + + @Test + fun saveConjugateModeType_unsupportedLanguageReturnsNoneFromIme() { + // Hindi is not a supported conjugation language — the IME default covers this. + every { ime.defaultConjugateModeType } returns "none" + + handler.saveConjugateModeType("Hindi", isSubsequent = false) + + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + assertEquals("none", prefs.getString("conjugate_mode_type", null)) + } + + @Test + fun saveConjugateModeType_subsequentSavesSubViewMode() { + handler.saveConjugateModeType("English", isSubsequent = true, subViewMode = "2x1") + + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + assertEquals("2x1", prefs.getString("conjugate_mode_type", null)) + } + + @Test + fun saveConjugateModeType_noneSentinelSavesNoneRegardlessOfImeDefault() { + // IME default is "2x2" but passing "none" as language resets to idle mode. + every { ime.defaultConjugateModeType } returns "2x2" + + handler.saveConjugateModeType("none", isSubsequent = false) + + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + assertEquals("none", prefs.getString("conjugate_mode_type", null)) + } + + @Test + fun handleConjugateKeys_whenNotSubsequent_commitsTextAndProcessesSuggestions() { + every { keyboardView.getKeyLabel(10) } returns "hablo" + + val label = handler.handleConjugateKeys(code = 10, isSubsequentRequired = false) + + assertEquals("hablo", label) + verify { inputConnection.commitText("hablo ", 1) } + verify { suggestionHandler.processLinguisticSuggestions("hablo") } + } + + @Test + fun handleConjugateKeys_whenSubsequentRequired_doesNotCommitText() { + every { keyboardView.getKeyLabel(10) } returns "hablo" + + val label = handler.handleConjugateKeys(code = 10, isSubsequentRequired = true) + + assertEquals("hablo", label) + verify(exactly = 0) { inputConnection.commitText(any(), any()) } + verify(exactly = 0) { suggestionHandler.processLinguisticSuggestions(any()) } + } + + @Test + fun handleConjugateKeys_whenKeyLabelNull_returnsNullAndDoesNotCommit() { + every { keyboardView.getKeyLabel(10) } returns null + + val label = handler.handleConjugateKeys(code = 10, isSubsequentRequired = false) + + assertNull(label) + verify(exactly = 0) { inputConnection.commitText(any(), any()) } + } + + @Test + fun setupConjugateSubView_withDataSizeZero_earlyReturnsWithoutInflatingKeyboard() { + every { ime.currentState } returns ScribeState.SELECT_VERB_CONJUNCTION + every { ime.defaultConjugateLayoutXML } returns R.xml.conjugate_view_3x2 + + // Empty data — flattenList.size == 0 hits else -> return before initializeKeyboard + handler.setupConjugateSubView(emptyList(), word = null) + + verify(exactly = 0) { uiManager.initializeKeyboard(any()) } + } + + @Test + fun setupConjugateSubView_withUnsupportedDataSize_earlyReturnsWithoutInflatingKeyboard() { + // 4 items → not 2 or 3, so setupConjugateSubView early-returns without inflating keyboard + val data = listOf(listOf("hablo", "hablas", "habla", "hablamos")) + handler.setupConjugateSubView(data, word = "hablo") + + verify(exactly = 0) { uiManager.initializeKeyboard(any()) } + } + + @Test + fun setupConjugateSubView_withTwoItems_configures2x1Layout() { + every { ime.language } returns "Spanish" + every { ime.currentState } returns ScribeState.SELECT_VERB_CONJUNCTION + every { ime.defaultConjugateModeType } returns "2x2" + + val data = listOf(listOf("hablo", "hablas")) + handler.setupConjugateSubView(data, word = "hablo") + + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + assertEquals("2x1", prefs.getString("conjugate_mode_type", null)) + verify { uiManager.initializeKeyboard(R.xml.conjugate_view_2x1) } + verify { keyboardView.setKeyLabel("hablo", "HI", KeyboardBase.CODE_2X1_TOP) } + verify { keyboardView.setKeyLabel("hablas", "HI", KeyboardBase.CODE_2X1_BOTTOM) } + assertEquals(View.GONE, binding.ivInfo.visibility) + assertFalse(handler.subsequentAreaRequired) + } + + @Test + fun setupConjugateSubView_withThreeItems_configures1x3Layout() { + every { ime.language } returns "Spanish" + every { ime.currentState } returns ScribeState.SELECT_VERB_CONJUNCTION + every { ime.defaultConjugateModeType } returns "2x2" + + val data = listOf(listOf("hablo", "hablas", "habla")) + handler.setupConjugateSubView(data, word = "hablo") + + val prefs = context.getSharedPreferences("keyboard_preferences", Context.MODE_PRIVATE) + assertEquals("1x3", prefs.getString("conjugate_mode_type", null)) + verify { uiManager.initializeKeyboard(R.xml.conjugate_view_1x3) } + verify { keyboardView.setKeyLabel("hablo", "HI", KeyboardBase.CODE_1X3_LEFT) } + verify { keyboardView.setKeyLabel("hablas", "HI", KeyboardBase.CODE_1X3_CENTER) } + verify { keyboardView.setKeyLabel("habla", "HI", KeyboardBase.CODE_1X3_RIGHT) } + assertEquals(View.GONE, binding.ivInfo.visibility) + assertFalse(handler.subsequentAreaRequired) + } +}