diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 788c10d05..5c73feda3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -281,10 +281,13 @@ dependencies { // ========================== testImplementation("org.junit.jupiter:junit-jupiter-api:$junit5Version") testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:$junit5Version") + testRuntimeOnly("org.junit.vintage:junit-vintage-engine:$junit5Version") testImplementation("io.mockk:mockk:$mockkVersion") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") testImplementation("junit:junit:4.13.2") testImplementation("org.robolectric:robolectric:4.13") + testImplementation("androidx.test:core-ktx:1.6.1") + testImplementation("androidx.test.ext:junit-ktx:1.2.1") testImplementation("androidx.compose.ui:ui-test-junit4:1.8.3") testImplementation("org.jetbrains.kotlin:kotlin-test:2.0.20") testImplementation("org.mockito:mockito-core:5.12.0") diff --git a/app/src/main/java/be/scri/helpers/ui/KeyboardUIManager.kt b/app/src/main/java/be/scri/helpers/ui/KeyboardUIManager.kt index 2edc82c17..3b8679688 100644 --- a/app/src/main/java/be/scri/helpers/ui/KeyboardUIManager.kt +++ b/app/src/main/java/be/scri/helpers/ui/KeyboardUIManager.kt @@ -221,6 +221,8 @@ class KeyboardUIManager( binding.ivInfo.visibility = View.GONE binding.conjugateGridContainer.visibility = View.GONE binding.keyboardView.visibility = View.VISIBLE + binding.invalidInfoBar.visibility = View.GONE + currentPage = 0 binding.scribeKeyOptions.foreground = AppCompatResources.getDrawable(context, R.drawable.ic_scribe_icon_vector) diff --git a/app/src/test/kotlin/be/scri/helpers/ui/KeyboardUIManagerTest.kt b/app/src/test/kotlin/be/scri/helpers/ui/KeyboardUIManagerTest.kt index 04d1cf85a..c5818c0a7 100644 --- a/app/src/test/kotlin/be/scri/helpers/ui/KeyboardUIManagerTest.kt +++ b/app/src/test/kotlin/be/scri/helpers/ui/KeyboardUIManagerTest.kt @@ -3,19 +3,12 @@ package be.scri.helpers.ui import android.content.Context +import android.view.LayoutInflater import android.view.View -import android.widget.Button -import android.widget.EditText -import android.widget.FrameLayout -import android.widget.LinearLayout -import android.widget.TextView -import androidx.appcompat.widget.AppCompatImageView -import androidx.constraintlayout.widget.ConstraintLayout import androidx.test.core.app.ApplicationProvider import be.scri.databinding.InputMethodViewBinding import be.scri.helpers.ui.KeyboardUIManager.KeyboardUIListener import be.scri.services.GeneralKeyboardIME.ScribeState -import be.scri.views.KeyboardView import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -35,140 +28,35 @@ class KeyboardUIManagerTest { private lateinit var listener: KeyboardUIListener private lateinit var uiManager: KeyboardUIManager - // Real Views (Robolectric handles these) - private lateinit var keyboardView: KeyboardView - private lateinit var commandOptionsBar: ConstraintLayout - private lateinit var toolbarBar: ConstraintLayout - private lateinit var translateBtn: Button - private lateinit var conjugateBtn: Button - private lateinit var pluralBtn: Button - private lateinit var translateBtnLeft: Button - private lateinit var translateBtnRight: Button - private lateinit var scribeKeyOptions: Button - private lateinit var separator2: View - private lateinit var separator3: View - private lateinit var commandBarEditText: EditText - private lateinit var promptText: TextView - private lateinit var ivInfo: AppCompatImageView - private lateinit var scribeKeyToolbar: Button - private lateinit var separator1: View - private lateinit var conjugateGridContainer: FrameLayout - private lateinit var conjugateGrid: LinearLayout - private lateinit var commandBarLayout: LinearLayout - - // Emoji Buttons - private lateinit var emojiBtnPhone1: Button - private lateinit var emojiBtnPhone2: Button - private lateinit var emojiSpacePhone: TextView - private lateinit var emojiBtnTablet1: Button - private lateinit var emojiBtnTablet2: Button - private lateinit var emojiBtnTablet3: Button - private lateinit var emojiSpaceTablet1: TextView - private lateinit var emojiSpaceTablet2: TextView - private lateinit var separator4: View - private lateinit var separator5: View - private lateinit var separator6: View - - // Invalid Info Views - private lateinit var invalidInfoBar: ConstraintLayout - private lateinit var invalidText: TextView - private lateinit var scribeKeyClose: Button - private lateinit var buttonLeft: Button - private lateinit var buttonRight: Button - private lateinit var middleTextview: TextView - private lateinit var pageIndicators: LinearLayout + // Convenience references resolved from the inflated binding. + private val keyboardView get() = binding.keyboardView + private val commandOptionsBar get() = binding.commandOptionsBar + private val toolbarBar get() = binding.toolbarBar + private val invalidInfoBar get() = binding.invalidInfoBar + private val ivInfo get() = binding.ivInfo + private val scribeKeyClose get() = binding.scribeKeyClose + private val translateBtn get() = binding.translateBtn + private val translateBtnLeft get() = binding.translateBtnLeft + private val conjugateGridContainer get() = binding.conjugateGridContainer + private val commandBarEditText get() = binding.commandBar + private val emojiBtnPhone1 get() = binding.emojiBtnPhone1 + private val emojiBtnPhone2 get() = binding.emojiBtnPhone2 + private val emojiBtnTablet1 get() = binding.emojiBtnTablet1 + private val emojiBtnTablet2 get() = binding.emojiBtnTablet2 + private val emojiBtnTablet3 get() = binding.emojiBtnTablet3 @Before fun setUp() { context = ApplicationProvider.getApplicationContext() - // Initialize Real Views - keyboardView = KeyboardView(context, null) - commandOptionsBar = ConstraintLayout(context) - toolbarBar = ConstraintLayout(context) - translateBtn = Button(context) - conjugateBtn = Button(context) - pluralBtn = Button(context) - translateBtnLeft = Button(context) - translateBtnRight = Button(context) - scribeKeyOptions = Button(context) - separator2 = View(context) - separator3 = View(context) - commandBarEditText = EditText(context) - promptText = TextView(context) - ivInfo = AppCompatImageView(context) - scribeKeyToolbar = Button(context) - separator1 = View(context) - conjugateGridContainer = FrameLayout(context) - conjugateGrid = LinearLayout(context) - commandBarLayout = LinearLayout(context) - - emojiBtnPhone1 = Button(context) - emojiBtnPhone2 = Button(context) - emojiSpacePhone = TextView(context) - emojiBtnTablet1 = Button(context) - emojiBtnTablet2 = Button(context) - emojiBtnTablet3 = Button(context) - emojiSpaceTablet1 = TextView(context) - emojiSpaceTablet2 = TextView(context) - separator4 = View(context) - separator5 = View(context) - separator6 = View(context) - - invalidInfoBar = ConstraintLayout(context) - invalidText = TextView(context) - scribeKeyClose = Button(context) - buttonLeft = Button(context) - buttonRight = Button(context) - middleTextview = TextView(context) - pageIndicators = LinearLayout(context) - - // Mock Binding to return our Real Views - binding = mockk(relaxed = true) - every { binding.root } returns ConstraintLayout(context) - every { binding.keyboardView } returns keyboardView - every { binding.commandOptionsBar } returns commandOptionsBar - every { binding.toolbarBar } returns toolbarBar - every { binding.translateBtn } returns translateBtn - every { binding.conjugateBtn } returns conjugateBtn - every { binding.pluralBtn } returns pluralBtn - every { binding.translateBtnLeft } returns translateBtnLeft - every { binding.translateBtnRight } returns translateBtnRight - every { binding.scribeKeyOptions } returns scribeKeyOptions - every { binding.separator2 } returns separator2 - every { binding.separator3 } returns separator3 - every { binding.commandBar } returns commandBarEditText - every { binding.promptText } returns promptText - every { binding.ivInfo } returns ivInfo - every { binding.scribeKeyToolbar } returns scribeKeyToolbar - every { binding.separator1 } returns separator1 - every { binding.conjugateGridContainer } returns conjugateGridContainer - every { binding.conjugateGrid } returns conjugateGrid - every { binding.commandBarLayout } returns commandBarLayout - - every { binding.emojiBtnPhone1 } returns emojiBtnPhone1 - every { binding.emojiBtnPhone2 } returns emojiBtnPhone2 - every { binding.emojiSpacePhone } returns emojiSpacePhone - every { binding.emojiBtnTablet1 } returns emojiBtnTablet1 - every { binding.emojiBtnTablet2 } returns emojiBtnTablet2 - every { binding.emojiBtnTablet3 } returns emojiBtnTablet3 - every { binding.emojiSpaceTablet1 } returns emojiSpaceTablet1 - every { binding.emojiSpaceTablet2 } returns emojiSpaceTablet2 - every { binding.separator4 } returns separator4 - every { binding.separator5 } returns separator5 - every { binding.separator6 } returns separator6 - - every { binding.invalidInfoBar } returns invalidInfoBar - every { binding.invalidText } returns invalidText - every { binding.scribeKeyClose } returns scribeKeyClose - every { binding.buttonLeft } returns buttonLeft - every { binding.buttonRight } returns buttonRight - every { binding.middleTextview } returns middleTextview - every { binding.pageIndicators } returns pageIndicators + // Inflate the real layout — Robolectric handles Android view inflation natively. + // This avoids MockK's inability to stub generated ViewBinding field accessors + // in Robolectric's sandboxed classloader. + binding = InputMethodViewBinding.inflate(LayoutInflater.from(context)) // Mock Listener listener = mockk(relaxed = true) - every { listener.getKeyboardLayoutXML() } returns 0 + every { listener.getKeyboardLayoutXML() } returns be.scri.R.xml.keys_letters_english every { listener.onKeyboardActionListener() } returns mockk() // Init Manager @@ -280,6 +168,86 @@ class KeyboardUIManagerTest { assertEquals("🚀", emojiBtnTablet3.text) } + @Test + fun `invalidInfoBar is hidden and keyboard restored when returning to IDLE after info panel shown from INVALID state`() { + // Transition to INVALID state. + uiManager.updateUI( + currentState = ScribeState.INVALID, + language = "English", + emojiAutoSuggestionEnabled = false, + autoSuggestEmojis = null, + conjugateOutput = null, + conjugateLabels = null, + selectedConjugationSubCategory = null, + currentVerbForConjugation = null, + ) + assertEquals("Toolbar bar should be visible in INVALID state", View.VISIBLE, toolbarBar.visibility) + assertEquals("Info icon should be visible in INVALID state", View.VISIBLE, ivInfo.visibility) + + // User taps ⓘ to open the info panel. + ivInfo.performClick() + assertEquals("invalidInfoBar should be visible after tapping info icon", View.VISIBLE, invalidInfoBar.visibility) + assertEquals("keyboardView should be GONE while info panel is open", View.GONE, keyboardView.visibility) + + // User taps X (scribeKeyClose) to simulate IME returning to IDLE. + scribeKeyClose.performClick() + verify { listener.onCloseClicked() } + + // Simulate what the IME does after onCloseClicked(): Transition UI to IDLE. + uiManager.updateUI( + currentState = ScribeState.IDLE, + language = "English", + emojiAutoSuggestionEnabled = false, + autoSuggestEmojis = null, + conjugateOutput = null, + conjugateLabels = null, + selectedConjugationSubCategory = null, + currentVerbForConjugation = null, + ) + + // Assert no orphaned UI elements remain. + assertEquals("invalidInfoBar must be GONE after returning to IDLE", View.GONE, invalidInfoBar.visibility) + assertEquals("keyboardView must be VISIBLE after returning to IDLE", View.VISIBLE, keyboardView.visibility) + assertEquals("commandOptionsBar must be VISIBLE in IDLE state", View.VISIBLE, commandOptionsBar.visibility) + } + + @Test + fun `invalidInfoBar is hidden and keyboard restored when returning to IDLE after info panel shown from ALREADY_PLURAL state`() { + // Transition to ALREADY_PLURAL state. + uiManager.updateUI( + currentState = ScribeState.ALREADY_PLURAL, + language = "English", + emojiAutoSuggestionEnabled = false, + autoSuggestEmojis = null, + conjugateOutput = null, + conjugateLabels = null, + selectedConjugationSubCategory = null, + currentVerbForConjugation = null, + ) + assertEquals("Info icon should be visible in ALREADY_PLURAL state", View.VISIBLE, ivInfo.visibility) + + // User taps ⓘ to open the info panel. + ivInfo.performClick() + assertEquals("invalidInfoBar should be visible after tapping info icon", View.VISIBLE, invalidInfoBar.visibility) + assertEquals("keyboardView should be GONE while info panel is open", View.GONE, keyboardView.visibility) + + // User taps X (scribeKeyClose) to simulate IME returning to IDLE. + uiManager.updateUI( + currentState = ScribeState.IDLE, + language = "English", + emojiAutoSuggestionEnabled = false, + autoSuggestEmojis = null, + conjugateOutput = null, + conjugateLabels = null, + selectedConjugationSubCategory = null, + currentVerbForConjugation = null, + ) + + // Assert that no orphaned UI elements remain. + assertEquals("invalidInfoBar must be GONE after returning to IDLE from ALREADY_PLURAL", View.GONE, invalidInfoBar.visibility) + assertEquals("keyboardView must be VISIBLE after returning to IDLE from ALREADY_PLURAL", View.VISIBLE, keyboardView.visibility) + } + @Test fun `disableAutoSuggest resets buttons to commands`() { uiManager.updateUI(ScribeState.IDLE, "English", false, null, null, null, null, null)