From 841960ecc5538a1f6bc988bd4728b9650d464238 Mon Sep 17 00:00:00 2001 From: Christian Basler Date: Tue, 13 Jan 2026 16:40:21 +0100 Subject: [PATCH] Add tests --- .../yaep/ui/common/focus/GridFocusable.kt | 3 +- .../ui/common/focus/GridSelectionManager.kt | 6 +- .../kotlin/ch/dissem/yaep/ui/common/grid.kt | 4 +- .../dissem/yaep/ui/common/layout/portrait.kt | 1 - .../ch/dissem/yaep/ui/common/AppTest.kt | 113 ++++++++++++++++++ .../common/focus/CluesSelectionManagerTest.kt | 2 +- .../FocusFollowingSelectionManagerTest.kt | 67 +++++++++++ .../common/focus/GridSelectionManagerTest.kt | 2 +- .../focus/LinearSelectionManagerTest.kt | 2 +- .../ui/common/focus/SelectionManagerTest.kt | 48 +++++++- .../kotlin/ch/dissem/yaep/domain/GameRow.kt | 21 +--- 11 files changed, 243 insertions(+), 26 deletions(-) create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/AppTest.kt create mode 100644 commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManagerTest.kt diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridFocusable.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridFocusable.kt index 79c5a30..6c4f71c 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridFocusable.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridFocusable.kt @@ -6,5 +6,6 @@ class GridFocusable( manager: GridSelectionManager, primaryAction: (() -> Unit)?, secondaryAction: (() -> Unit)?, - onKeyEvent: ((KeyEvent) -> Boolean)? = null + onKeyEvent: ((KeyEvent) -> Boolean)? = null, + val position: String ) : Focusable(manager, primaryAction, secondaryAction, onKeyEvent) \ No newline at end of file diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManager.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManager.kt index 052c4a5..37bc37c 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManager.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManager.kt @@ -21,7 +21,8 @@ class GridSelectionManager : SelectionManager() { manager = this, primaryAction = null, secondaryAction = null, - onKeyEvent = onKeyEvent + onKeyEvent = onKeyEvent, + position = "${grid.last().size + 1}/${grid.size}" ) grid.last().add(new) return new @@ -34,7 +35,8 @@ class GridSelectionManager : SelectionManager() { val new = GridFocusable( manager = this, primaryAction = primaryAction, - secondaryAction = secondaryAction + secondaryAction = secondaryAction, + position = "${grid.last().size + 1}/${grid.size}" ) grid.last().add(new) return new diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/grid.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/grid.kt index 01e1b0b..12d1d6e 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/grid.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/grid.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.key +import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import ch.dissem.yaep.domain.GameCell @@ -112,7 +113,8 @@ private fun RowScope.PuzzleCell( modifier = Modifier .focus(focusable) .padding(spacing) - .weight(1f), + .weight(1f) + .testTag(focusable.position), spacing, options = options, onOptionRemoved = { selectedItem -> diff --git a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt index 96fab6c..1298e0c 100644 --- a/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt +++ b/commonUI/src/commonMain/kotlin/ch/dissem/yaep/ui/common/layout/portrait.kt @@ -89,7 +89,6 @@ internal fun Placeable.PlacementScope.portrait( offsetY = verticalCluesOffsetY, maxWidth = gridSize ) - verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY) } // Position the time val remainingSpace = constraints.maxHeight - offsetY diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/AppTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/AppTest.kt new file mode 100644 index 0000000..ff9394e --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/AppTest.kt @@ -0,0 +1,113 @@ +package ch.dissem.yaep.ui.common + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.SkikoComposeUiTest +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.runSkikoComposeUiTest +import androidx.compose.ui.unit.dp +import ch.dissem.yaep.domain.Game +import ch.dissem.yaep.domain.UnsolvablePuzzleException +import ch.dissem.yaep.ui.common.focus.FocusFollowingSelectionManager +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect +import kotlin.test.Test +import kotlin.test.fail + +@OptIn(ExperimentalTestApi::class) +class AppTest { + val game = createGame() + var resetCluesBeacon by mutableStateOf(Any()) + + @Test + fun ensure_app_can_be_rendered_on_1920x1080_and_can_be_solved() = ensure_app_can_be_rendered_on( + Size(1920f, 1080f) + ) { + val clues = game.clues + val grid = game.grid + var removedOptions: Boolean + do { + removedOptions = false + try { + clues.forEach { clue -> + val optionsRemoved = clue.removeForbiddenOptions(grid) + removedOptions = optionsRemoved || removedOptions + if (optionsRemoved) { + resetCluesBeacon = Any() + } + } + } catch (_: UnsolvablePuzzleException) { + fail("puzzle can't be solved") + } + grid.cleanupOptions() + resetCluesBeacon = Any() + } while (removedOptions) + + expect(game.isSolved).toEqual(true) + onNodeWithTag("EndOfGame.solved_time").assertExists("End of Game must be shown when puzzle is solved") + } + + @Test + fun ensure_app_can_be_rendered_on_1080x1920() = ensure_app_can_be_rendered_on( + Size(1080f, 1920f) + ) + + @Test + fun ensure_app_can_be_rendered_on_800x600() = ensure_app_can_be_rendered_on( + Size(800f, 600f) + ) + + @Test + fun ensure_app_can_be_rendered_on_600x800() = ensure_app_can_be_rendered_on( + Size(600f, 800f) + ) + + fun ensure_app_can_be_rendered_on( + screenSize: Size, + block: suspend SkikoComposeUiTest.() -> Unit = {} + ) = runSkikoComposeUiTest(size = screenSize) { + setContent { + App( + modifier = Modifier.fillMaxSize(), + rootSelectionManager = FocusFollowingSelectionManager, + spacing = 8.dp, + game = game, + onNewGame = { }, + resetCluesBeacon = resetCluesBeacon + ) + } + block() + } + + private fun createGame(): Game = Game.parse( + """ + * MATE is between the neighbours TEACHER and GOAT to both sides + * SCIENTIST is between the neighbours SPAIN and FIREFIGHTER to both sides + * TEA is left of COFFEE + * ZEBRA is between the neighbours LEMON and CAKE to both sides + * CUPCAKE is left of GOAT + * ROCK_STAR is left of SPAIN + * PEAR is at position 1 + * CHOCOLATE is between the neighbours TEA and MILK to both sides + * FARMER is between the neighbours PEAR and CHERRIES to both sides + * SWITZERLAND is between the neighbours JAPAN and DOG to both sides + * MANGO and SPAIN are in the same column + * SPAIN is next to SNAIL + * CANADA is next to PIE + * JAPAN is between the neighbours SWITZERLAND and CAKE to both sides + * FIREFIGHTER is between the neighbours GOAT and STRAWBERRY to both sides + * UKRAINE is between the neighbours ANT and BEVERAGE to both sides + * CHOCOLATE and SWITZERLAND are in the same column + * PEAR is left of COFFEE + * JAPAN is between the neighbours CAKE and COFFEE to both sides + * BEVERAGE is between the neighbours CUSTARD and UKRAINE to both sides + * CANADA and CAKE are in the same column + """.trimIndent() + ) + +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManagerTest.kt index 9d11270..c0923f2 100644 --- a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManagerTest.kt +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/CluesSelectionManagerTest.kt @@ -5,7 +5,7 @@ import ch.tutteli.atrium.api.fluent.en_GB.toEqual import ch.tutteli.atrium.api.verbs.expect import kotlin.test.Test -class CluesSelectionManagerTest : SelectionManagerTest() { +class CluesSelectionManagerTest : SelectionManagerTest() { var primaryActionCalled: Int? = null var secondaryActionCalled: Int? = null diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManagerTest.kt new file mode 100644 index 0000000..54ff7a0 --- /dev/null +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/FocusFollowingSelectionManagerTest.kt @@ -0,0 +1,67 @@ +package ch.dissem.yaep.ui.common.focus + +import androidx.compose.ui.focus.FocusState +import ch.tutteli.atrium.api.fluent.en_GB.notToEqual +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect +import kotlin.test.Test + +class FocusFollowingSelectionManagerTest : + SelectionManagerTest() { + + var focusables = mutableListOf() + + val active = object : FocusState { + override val isFocused: Boolean = true + override val hasFocus: Boolean = true + override val isCaptured: Boolean = true + } + val inactive = object : FocusState { + override val isFocused: Boolean = false + override val hasFocus: Boolean = false + override val isCaptured: Boolean = false + } + + override fun setUp() { + manager = FocusFollowingSelectionManager + manager.focused = null + + focusables = mutableListOf() + repeat(4) { + focusables.add(manager.add()) + } + } + + @Test + fun ensure_focus_is_followed() { + expect(manager.focused).toEqual(null) + for (focusable in focusables) { + expect(manager.focused).notToEqual(focusable) + focusable.setFocus(active) + expect(manager.focused).toEqual(focusable) + } + } + + @Test + fun ensure_focus_is_lost_when_same_focusable_is_set_inactive() { + for (focusable in focusables) { + focusable.setFocus(active) + expect(manager.focused).toEqual(focusable) + focusable.setFocus(inactive) + expect(manager.focused).toEqual(null) + } + } + + @Test + fun ensure_focus_is_not_lost_if_different_focusable_is_set_inactive() { + var lastFocused: FocusFollowingFocusable? = null + for (focusable in focusables) { + focusable.setFocus(inactive) + expect(manager.focused).toEqual(lastFocused) + focusable.setFocus(active) + expect(manager.focused).toEqual(focusable) + lastFocused = focusable + } + expect(manager.focused).toEqual(lastFocused) + } +} \ No newline at end of file diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManagerTest.kt index 25caf6f..af4077e 100644 --- a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManagerTest.kt +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/GridSelectionManagerTest.kt @@ -5,7 +5,7 @@ import ch.tutteli.atrium.api.fluent.en_GB.toEqual import ch.tutteli.atrium.api.verbs.expect import kotlin.test.Test -class GridSelectionManagerTest : SelectionManagerTest() { +class GridSelectionManagerTest : SelectionManagerTest() { lateinit var focusables: MutableList diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManagerTest.kt index 275a87e..5e5f611 100644 --- a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManagerTest.kt +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/LinearSelectionManagerTest.kt @@ -5,7 +5,7 @@ import ch.tutteli.atrium.api.fluent.en_GB.toEqual import ch.tutteli.atrium.api.verbs.expect import kotlin.test.Test -class LinearSelectionManagerTest : SelectionManagerTest() { +class LinearSelectionManagerTest : SelectionManagerTest() { override fun setUp() { manager = LinearSelectionManager( diff --git a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManagerTest.kt b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManagerTest.kt index 45c497e..7739ee3 100644 --- a/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManagerTest.kt +++ b/commonUI/src/commonTest/kotlin/ch/dissem/yaep/ui/common/focus/SelectionManagerTest.kt @@ -4,14 +4,60 @@ import androidx.compose.ui.InternalComposeUiApi import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEventType +import ch.tutteli.atrium.api.fluent.en_GB.toEqual +import ch.tutteli.atrium.api.verbs.expect import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.fail -abstract class SelectionManagerTest> { +abstract class SelectionManagerTest, M : SelectionManager> { lateinit var manager: M @BeforeTest abstract fun setUp() + @Test + fun ensure_key_event_is_ignored_if_not_keyup() { + for (t in arrayOf(KeyEventType.KeyDown, KeyEventType.Unknown)) { + manager.focused = manager.add { + fail("Action must not be called, event type $t should be ignored") + } + manager.onKeyEvent(keyEvent(Key.Enter, t)) + } + } + + @Test + fun ensure_primary_action_is_called() { + for (k in arrayOf(Key.Enter, Key.Spacebar)) { + var primary = false + var secondary = false + + manager.focused = manager.add( + primaryAction = { primary = true }, + secondaryAction = { secondary = true } + ) + manager.onKeyEvent(keyEvent(k)) + expect(primary).toEqual(true) + expect(secondary).toEqual(false) + } + } + + @Test + fun ensure_secondary_action_is_called() { + for (k in arrayOf(Key.Backspace, Key.Delete)) { + var primary = false + var secondary = false + + manager.focused = manager.add( + primaryAction = { primary = true }, + secondaryAction = { secondary = true } + ) + manager.onKeyEvent(keyEvent(k)) + expect(primary).toEqual(false) + expect(secondary).toEqual(true) + } + } + @OptIn(InternalComposeUiApi::class) fun keyEvent(key: Key, type: KeyEventType = KeyEventType.KeyUp) = KeyEvent( key = key, diff --git a/domain/src/commonMain/kotlin/ch/dissem/yaep/domain/GameRow.kt b/domain/src/commonMain/kotlin/ch/dissem/yaep/domain/GameRow.kt index b87e9c7..1ecfc55 100644 --- a/domain/src/commonMain/kotlin/ch/dissem/yaep/domain/GameRow.kt +++ b/domain/src/commonMain/kotlin/ch/dissem/yaep/domain/GameRow.kt @@ -1,6 +1,5 @@ package ch.dissem.yaep.domain -@Suppress("JavaDefaultMethodsNotOverriddenByDelegation") class GameRow>( val category: ItemClassCompanion, val options: List>, @@ -22,12 +21,8 @@ class GameRow>( fun indexOf(element: C): Int = indexOfFirst { it.selection?.itemType == element } fun cleanupOptions() { - if (isSolved && all { - it.solution != null - && it.options.size == 1 - && it.options.single() == it.selection - } - ) return + if (isSolved && all { it.options.size == 1 && it.options.single() == it.selection }) + return do { var changed = false val selections = filter { it.selection != null }.let { cellsWithSelection -> @@ -58,11 +53,7 @@ class GameRow>( } } } while (changed) - isSolved = all { - it.solution != null - && it.options.size == 1 - && it.options.single() == it.selection - } + isSolved = all { it.options.size == 1 && it.options.single() == it.selection } } fun snapshot() { @@ -72,11 +63,7 @@ class GameRow>( fun undo(): Boolean { val didChange = cells.map { it.undo() }.reduce { a, b -> a || b } if (didChange) { - isSolved = all { - it.solution != null - && it.options.size == 1 - && it.options.single() == it.selection - } + isSolved = all { it.options.size == 1 && it.options.single() == it.selection } } return didChange }