diff --git a/docs/testing/issue-930.tdd.md b/docs/testing/issue-930.tdd.md new file mode 100644 index 000000000..897d5fd10 --- /dev/null +++ b/docs/testing/issue-930.tdd.md @@ -0,0 +1,59 @@ +# Issue #930 TDD Evidence + +## Source and user journey + +- Source: +- As a user flicking a key on the bottom row in portrait mode, I want the + selected character to remain visible while flicking down, even though there + is no keyboard space below the key. +- As a user holding that key, I want all five long-press guide values, including + the bottom-flick `0`, to remain visible without overlapping. + +## Execution report + +- RED: `PopupWindowExtensionTest` was added before the placement helper existed. + Running the focused test failed during test compilation with + `Unresolved reference: calculateFlickBottomPopupPlacement`. +- GREEN: the bottom-flick placement now detects portrait bottom-row keys and + places their popup above the anchor with a downward-pointing arrow. The same + focused command passed all three placement cases. +- RED: the long-press guide tests initially failed to compile because its + placement helper did not exist. A second RED showed that fixed-height spacing + overlapped when the popup size was increased. +- GREEN: portrait bottom-row long-press guides now shift the top, left, center, + and right values inward while placing the bottom value over the anchor. The + spacing uses both popup and anchor height, so configured popup scaling remains + separated. +- Regression: all 13 TenKey unit tests passed, and + `:app:assembleLiteStandardDebug` completed successfully. +- Emulator: `:app:installLiteStandardDebug` installed the updated APK on + `Medium_Phone_API_36.1`, and the Lite IME was selected again. + +## Test specification + +| # | What is guaranteed | Test or command | Type | Result | +|---|---|---|---|---| +| 1 | A portrait bottom-row down-flick is shown above the key instead of being omitted below the keyboard | `PopupWindowExtensionTest.portraitBottomRowFlickBottomIsPlacedAboveTheAnchor` | Unit | PASS | +| 2 | A portrait non-bottom-row down-flick keeps its normal directional placement | `PopupWindowExtensionTest.portraitNonBottomRowFlickBottomKeepsTheDirectionalPlacement` | Unit | PASS | +| 3 | A landscape bottom-row down-flick keeps its existing directional placement | `PopupWindowExtensionTest.landscapeBottomRowFlickBottomKeepsTheDirectionalPlacement` | Unit | PASS | +| 4 | A portrait bottom-row long press displays the complete five-way guide inside the keyboard edge | `PopupWindowExtensionTest.portraitBottomRowLongPressGuideIsShiftedAboveTheKeyboardEdge` | Unit | PASS | +| 5 | Enlarged long-press popups use scaled spacing and do not collapse onto each other | `PopupWindowExtensionTest.portraitBottomRowLongPressGuideUsesTheScaledPopupSpacing` | Unit | PASS | +| 6 | Non-bottom portrait and landscape guides retain their previous offsets | `PopupWindowExtensionTest.portraitNonBottomRowLongPressGuideKeepsItsExistingOffsets`, `landscapeBottomRowLongPressGuideKeepsItsExistingOffsets` | Unit | PASS | +| 7 | Existing TenKey behavior remains valid | `:tenkey:testDebugUnitTest` | Unit, 13 tests | PASS | +| 8 | The Lite Standard debug application packages successfully | `:app:assembleLiteStandardDebug` | Build | PASS | +| 9 | The modified TenKey module satisfies Android Lint | `:tenkey:lintDebug` | Static analysis | PASS | + +## Coverage and known gaps + +No JaCoCo task is configured for this module, so no numeric coverage percentage +is available. The placement helpers' bottom-row, non-bottom-row, portrait, +landscape, default-size, and scaled-size branches are exercised directly. + +The complete Lite application unit suite executed 1,260 tests, with 1,253 +passing, 4 skipped, and 3 unrelated existing failures in SQLite migration and +system n-gram tests. Application-wide lint also remains blocked by 15 existing +errors, beginning with a `MissingPermission` error in +`GemmaImeMediaPanelController.kt`; TenKey lint passes. + +RED and GREEN were recorded without checkpoint commits because repository +instructions reserve commits and GPG authentication for the user. diff --git a/tenkey/src/main/java/com/kazumaproject/tenkey/extensions/PopupWindowExtension.kt b/tenkey/src/main/java/com/kazumaproject/tenkey/extensions/PopupWindowExtension.kt index 55aa31f91..542d63c17 100644 --- a/tenkey/src/main/java/com/kazumaproject/tenkey/extensions/PopupWindowExtension.kt +++ b/tenkey/src/main/java/com/kazumaproject/tenkey/extensions/PopupWindowExtension.kt @@ -26,6 +26,67 @@ private fun calculateFlickTopYOffset(anchorHeight: Int): Int { return -(anchorHeight * 2) - 16 } +private fun isPortraitBottomRowAnchor(anchorId: Int, orientation: Int): Boolean { + return orientation != Configuration.ORIENTATION_LANDSCAPE && + (anchorId == R.id.key_small_letter || anchorId == R.id.key_11 || anchorId == R.id.key_12) +} + +internal enum class LongPressPopupPosition { + TOP, + LEFT, + CENTER, + RIGHT, + BOTTOM +} + +internal fun calculateLongPressPopupYOffset( + position: LongPressPopupPosition, + anchorId: Int, + orientation: Int, + popupHeight: Int, + anchorHeight: Int +): Int { + val centeredOnAnchor = -((popupHeight + anchorHeight) / 2) + val isBottomRow = isPortraitBottomRowAnchor(anchorId, orientation) + val guideStep = (popupHeight + anchorHeight) / 2 + return when (position) { + LongPressPopupPosition.TOP -> -popupHeight - anchorHeight - if (isBottomRow) guideStep else 0 + LongPressPopupPosition.BOTTOM -> if (isBottomRow) centeredOnAnchor else 0 + LongPressPopupPosition.LEFT, + LongPressPopupPosition.CENTER, + LongPressPopupPosition.RIGHT -> centeredOnAnchor - if (isBottomRow) guideStep else 0 + } +} + +internal data class FlickBottomPopupPlacement( + val arrowDirection: ArrowDirection, + val xOffset: Int, + val yOffset: Int +) + +internal fun calculateFlickBottomPopupPlacement( + anchorId: Int, + orientation: Int, + popupWidth: Int, + anchorWidth: Int, + anchorHeight: Int +): FlickBottomPopupPlacement { + val isPortraitBottomRow = isPortraitBottomRowAnchor(anchorId, orientation) + return FlickBottomPopupPlacement( + arrowDirection = if (isPortraitBottomRow) { + ArrowDirection.BOTTOM_CENTER + } else { + ArrowDirection.TOP_CENTER + }, + xOffset = calculateCenteredXOffset(popupWidth, anchorWidth), + yOffset = if (isPortraitBottomRow) { + calculateFlickTopYOffset(anchorHeight) + } else { + -(anchorHeight / 2) - 8 + } + ) +} + fun PopupWindow.setPopUpWindowFlickRight( context: Context, keyWindowLayout: KeyWindowLayout, @@ -140,63 +201,31 @@ fun PopupWindow.setPopUpWindowFlickBottom( this.width = baseWidth.scaledPopupSize(sizeScalePercent) this.height = baseHeight.scaledPopupSize(sizeScalePercent) this.setBackgroundDrawable(Color.TRANSPARENT.toDrawable()) + val placement = calculateFlickBottomPopupPlacement( + anchorId = anchorView.id, + orientation = context.resources.configuration.orientation, + popupWidth = width, + anchorWidth = anchorView.width, + anchorHeight = anchorView.height + ) keyWindowLayout.let { bubble -> - if (bubble.arrowDirection != ArrowDirection.TOP_CENTER) this.dismiss() - bubble.arrowDirection = ArrowDirection.TOP_CENTER + if (bubble.arrowDirection != placement.arrowDirection) this.dismiss() + bubble.arrowDirection = placement.arrowDirection bubble.arrowHeight = (anchorView.height / 2).toFloat() - 8 bubble.arrowWidth = anchorView.width.toFloat() - 10 bubble.cornersRadius = 20f } when (context.resources.configuration.orientation) { - Configuration.ORIENTATION_PORTRAIT -> { - when (anchorView.id) { - R.id.key_small_letter, - R.id.key_11, - R.id.key_12 -> { - - } - - else -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -(anchorView.height / 2) - 8, - Gravity.CENTER - ) - } - } - } - - Configuration.ORIENTATION_LANDSCAPE -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -(anchorView.height / 2) - 8, - Gravity.CENTER - ) - } - - Configuration.ORIENTATION_UNDEFINED -> { - when (anchorView.id) { - R.id.key_small_letter, - R.id.key_11, - R.id.key_12 -> { - - } - - else -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -(anchorView.height / 2) - 8, - Gravity.CENTER - ) - } - } - } + Configuration.ORIENTATION_PORTRAIT, + Configuration.ORIENTATION_LANDSCAPE, + Configuration.ORIENTATION_UNDEFINED -> showAsDropDown( + anchorView, + placement.xOffset, + placement.yOffset, + Gravity.CENTER + ) else -> { - } } } @@ -272,33 +301,19 @@ fun PopupWindow.setPopUpWindowCenter( bubble.arrowWidth = 0f bubble.arrowHeight = 0f } - when (context.resources.configuration.orientation) { - Configuration.ORIENTATION_PORTRAIT -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } - - Configuration.ORIENTATION_LANDSCAPE -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } - - Configuration.ORIENTATION_UNDEFINED -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } + val orientation = context.resources.configuration.orientation + val yOffset = calculateLongPressPopupYOffset( + LongPressPopupPosition.CENTER, anchorView.id, orientation, height, anchorView.height + ) + when (orientation) { + Configuration.ORIENTATION_PORTRAIT, + Configuration.ORIENTATION_LANDSCAPE, + Configuration.ORIENTATION_UNDEFINED -> showAsDropDown( + anchorView, + -((width - anchorView.width) / 2), + yOffset, + Gravity.CENTER + ) else -> {} } @@ -319,33 +334,19 @@ fun PopupWindow.setPopUpWindowRight( bubble.arrowWidth = 0f bubble.arrowHeight = 0f } - when (context.resources.configuration.orientation) { - Configuration.ORIENTATION_PORTRAIT -> { - showAsDropDown( - anchorView, - anchorView.width, - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } - - Configuration.ORIENTATION_LANDSCAPE -> { - showAsDropDown( - anchorView, - anchorView.width, - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } - - Configuration.ORIENTATION_UNDEFINED -> { - showAsDropDown( - anchorView, - anchorView.width, - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } + val orientation = context.resources.configuration.orientation + val yOffset = calculateLongPressPopupYOffset( + LongPressPopupPosition.RIGHT, anchorView.id, orientation, height, anchorView.height + ) + when (orientation) { + Configuration.ORIENTATION_PORTRAIT, + Configuration.ORIENTATION_LANDSCAPE, + Configuration.ORIENTATION_UNDEFINED -> showAsDropDown( + anchorView, + anchorView.width, + yOffset, + Gravity.CENTER + ) else -> {} } @@ -366,37 +367,21 @@ fun PopupWindow.setPopUpWindowLeft( bubble.arrowWidth = 0f bubble.arrowHeight = 0f } - when (context.resources.configuration.orientation) { - Configuration.ORIENTATION_PORTRAIT -> { - showAsDropDown( - anchorView, - -width, - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } + val orientation = context.resources.configuration.orientation + val yOffset = calculateLongPressPopupYOffset( + LongPressPopupPosition.LEFT, anchorView.id, orientation, height, anchorView.height + ) + when (orientation) { + Configuration.ORIENTATION_PORTRAIT, + Configuration.ORIENTATION_LANDSCAPE, + Configuration.ORIENTATION_UNDEFINED -> showAsDropDown( + anchorView, + -width, + yOffset, + Gravity.CENTER + ) - Configuration.ORIENTATION_LANDSCAPE -> { - showAsDropDown( - anchorView, - -width, - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } - - Configuration.ORIENTATION_UNDEFINED -> { - showAsDropDown( - anchorView, - -width, - -((height + anchorView.height) / 2), - Gravity.CENTER - ) - } - - else -> { - - } + else -> {} } } @@ -416,57 +401,21 @@ fun PopupWindow.setPopUpWindowBottom( bubble.arrowWidth = 0f bubble.arrowHeight = 0f } - when (context.resources.configuration.orientation) { - Configuration.ORIENTATION_PORTRAIT -> { - when (anchorView.id) { - R.id.key_small_letter, - R.id.key_11, - R.id.key_12 -> { - - } - - else -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - 0, - Gravity.CENTER - ) - } - } - } - - Configuration.ORIENTATION_LANDSCAPE -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - 0, - Gravity.CENTER - ) - } + val orientation = context.resources.configuration.orientation + val yOffset = calculateLongPressPopupYOffset( + LongPressPopupPosition.BOTTOM, anchorView.id, orientation, height, anchorView.height + ) + when (orientation) { + Configuration.ORIENTATION_PORTRAIT, + Configuration.ORIENTATION_LANDSCAPE, + Configuration.ORIENTATION_UNDEFINED -> showAsDropDown( + anchorView, + -((width - anchorView.width) / 2), + yOffset, + Gravity.CENTER + ) - Configuration.ORIENTATION_UNDEFINED -> { - when (anchorView.id) { - R.id.key_small_letter, - R.id.key_11, - R.id.key_12 -> { - - } - - else -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - 0, - Gravity.CENTER - ) - } - } - } - - else -> { - - } + else -> {} } } @@ -485,36 +434,20 @@ fun PopupWindow.setPopUpWindowTop( bubble.arrowWidth = 0f bubble.arrowHeight = 0f } - when (context.resources.configuration.orientation) { - Configuration.ORIENTATION_PORTRAIT -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -height - anchorView.height, - Gravity.CENTER - ) - } + val orientation = context.resources.configuration.orientation + val yOffset = calculateLongPressPopupYOffset( + LongPressPopupPosition.TOP, anchorView.id, orientation, height, anchorView.height + ) + when (orientation) { + Configuration.ORIENTATION_PORTRAIT, + Configuration.ORIENTATION_LANDSCAPE, + Configuration.ORIENTATION_UNDEFINED -> showAsDropDown( + anchorView, + -((width - anchorView.width) / 2), + yOffset, + Gravity.CENTER + ) - Configuration.ORIENTATION_LANDSCAPE -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -height - anchorView.height, - Gravity.CENTER - ) - } - - Configuration.ORIENTATION_UNDEFINED -> { - showAsDropDown( - anchorView, - -((width - anchorView.width) / 2), - -height - anchorView.height, - Gravity.CENTER - ) - } - - else -> { - - } + else -> {} } } diff --git a/tenkey/src/test/java/com/kazumaproject/tenkey/extensions/PopupWindowExtensionTest.kt b/tenkey/src/test/java/com/kazumaproject/tenkey/extensions/PopupWindowExtensionTest.kt new file mode 100644 index 000000000..178da567f --- /dev/null +++ b/tenkey/src/test/java/com/kazumaproject/tenkey/extensions/PopupWindowExtensionTest.kt @@ -0,0 +1,138 @@ +package com.kazumaproject.tenkey.extensions + +import android.content.res.Configuration +import com.kazumaproject.core.ui.key_window.ArrowDirection +import com.kazumaproject.tenkey.R +import org.junit.Assert.assertEquals +import org.junit.Test + +class PopupWindowExtensionTest { + + @Test + fun portraitBottomRowLongPressGuideIsShiftedAboveTheKeyboardEdge() { + val offsets = LongPressPopupPosition.entries.associateWith { position -> + calculateLongPressPopupYOffset( + position = position, + anchorId = R.id.key_11, + orientation = Configuration.ORIENTATION_PORTRAIT, + popupHeight = 48, + anchorHeight = 48 + ) + } + + assertEquals(-144, offsets[LongPressPopupPosition.TOP]) + assertEquals(-96, offsets[LongPressPopupPosition.LEFT]) + assertEquals(-96, offsets[LongPressPopupPosition.CENTER]) + assertEquals(-96, offsets[LongPressPopupPosition.RIGHT]) + assertEquals(-48, offsets[LongPressPopupPosition.BOTTOM]) + } + + @Test + fun portraitBottomRowLongPressGuideUsesTheScaledPopupSpacing() { + val offsetFor = { position: LongPressPopupPosition -> + calculateLongPressPopupYOffset( + position = position, + anchorId = R.id.key_11, + orientation = Configuration.ORIENTATION_PORTRAIT, + popupHeight = 96, + anchorHeight = 48 + ) + } + + assertEquals(-216, offsetFor(LongPressPopupPosition.TOP)) + assertEquals(-144, offsetFor(LongPressPopupPosition.CENTER)) + assertEquals(-72, offsetFor(LongPressPopupPosition.BOTTOM)) + } + + @Test + fun portraitNonBottomRowLongPressGuideKeepsItsExistingOffsets() { + assertEquals( + -48, + calculateLongPressPopupYOffset( + position = LongPressPopupPosition.CENTER, + anchorId = R.id.key_5, + orientation = Configuration.ORIENTATION_PORTRAIT, + popupHeight = 48, + anchorHeight = 48 + ) + ) + assertEquals( + 0, + calculateLongPressPopupYOffset( + position = LongPressPopupPosition.BOTTOM, + anchorId = R.id.key_5, + orientation = Configuration.ORIENTATION_PORTRAIT, + popupHeight = 48, + anchorHeight = 48 + ) + ) + } + + @Test + fun landscapeBottomRowLongPressGuideKeepsItsExistingOffsets() { + assertEquals( + -48, + calculateLongPressPopupYOffset( + position = LongPressPopupPosition.CENTER, + anchorId = R.id.key_11, + orientation = Configuration.ORIENTATION_LANDSCAPE, + popupHeight = 48, + anchorHeight = 48 + ) + ) + assertEquals( + 0, + calculateLongPressPopupYOffset( + position = LongPressPopupPosition.BOTTOM, + anchorId = R.id.key_11, + orientation = Configuration.ORIENTATION_LANDSCAPE, + popupHeight = 48, + anchorHeight = 48 + ) + ) + } + + @Test + fun portraitBottomRowFlickBottomIsPlacedAboveTheAnchor() { + val placement = calculateFlickBottomPopupPlacement( + anchorId = R.id.key_11, + orientation = Configuration.ORIENTATION_PORTRAIT, + popupWidth = 80, + anchorWidth = 80, + anchorHeight = 48 + ) + + assertEquals(ArrowDirection.BOTTOM_CENTER, placement.arrowDirection) + assertEquals(0, placement.xOffset) + assertEquals(-112, placement.yOffset) + } + + @Test + fun portraitNonBottomRowFlickBottomKeepsTheDirectionalPlacement() { + val placement = calculateFlickBottomPopupPlacement( + anchorId = R.id.key_5, + orientation = Configuration.ORIENTATION_PORTRAIT, + popupWidth = 80, + anchorWidth = 80, + anchorHeight = 48 + ) + + assertEquals(ArrowDirection.TOP_CENTER, placement.arrowDirection) + assertEquals(0, placement.xOffset) + assertEquals(-32, placement.yOffset) + } + + @Test + fun landscapeBottomRowFlickBottomKeepsTheDirectionalPlacement() { + val placement = calculateFlickBottomPopupPlacement( + anchorId = R.id.key_11, + orientation = Configuration.ORIENTATION_LANDSCAPE, + popupWidth = 80, + anchorWidth = 80, + anchorHeight = 48 + ) + + assertEquals(ArrowDirection.TOP_CENTER, placement.arrowDirection) + assertEquals(-32, placement.yOffset) + } +}