Add tests

This commit is contained in:
2026-01-13 16:40:21 +01:00
committed by Christian Basler
parent b207754636
commit 841960ecc5
11 changed files with 243 additions and 26 deletions

View File

@@ -6,5 +6,6 @@ class GridFocusable(
manager: GridSelectionManager, manager: GridSelectionManager,
primaryAction: (() -> Unit)?, primaryAction: (() -> Unit)?,
secondaryAction: (() -> Unit)?, secondaryAction: (() -> Unit)?,
onKeyEvent: ((KeyEvent) -> Boolean)? = null onKeyEvent: ((KeyEvent) -> Boolean)? = null,
val position: String
) : Focusable<GridFocusable>(manager, primaryAction, secondaryAction, onKeyEvent) ) : Focusable<GridFocusable>(manager, primaryAction, secondaryAction, onKeyEvent)

View File

@@ -21,7 +21,8 @@ class GridSelectionManager : SelectionManager<GridFocusable>() {
manager = this, manager = this,
primaryAction = null, primaryAction = null,
secondaryAction = null, secondaryAction = null,
onKeyEvent = onKeyEvent onKeyEvent = onKeyEvent,
position = "${grid.last().size + 1}/${grid.size}"
) )
grid.last().add(new) grid.last().add(new)
return new return new
@@ -34,7 +35,8 @@ class GridSelectionManager : SelectionManager<GridFocusable>() {
val new = GridFocusable( val new = GridFocusable(
manager = this, manager = this,
primaryAction = primaryAction, primaryAction = primaryAction,
secondaryAction = secondaryAction secondaryAction = secondaryAction,
position = "${grid.last().size + 1}/${grid.size}"
) )
grid.last().add(new) grid.last().add(new)
return new return new

View File

@@ -17,6 +17,7 @@ import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.isShiftPressed import androidx.compose.ui.input.key.isShiftPressed
import androidx.compose.ui.input.key.key 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 androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ch.dissem.yaep.domain.GameCell import ch.dissem.yaep.domain.GameCell
@@ -112,7 +113,8 @@ private fun RowScope.PuzzleCell(
modifier = Modifier modifier = Modifier
.focus(focusable) .focus(focusable)
.padding(spacing) .padding(spacing)
.weight(1f), .weight(1f)
.testTag(focusable.position),
spacing, spacing,
options = options, options = options,
onOptionRemoved = { selectedItem -> onOptionRemoved = { selectedItem ->

View File

@@ -89,7 +89,6 @@ internal fun Placeable.PlacementScope.portrait(
offsetY = verticalCluesOffsetY, offsetY = verticalCluesOffsetY,
maxWidth = gridSize maxWidth = gridSize
) )
verticalCluesBoxPlaceable.place(0, verticalCluesOffsetY)
} }
// Position the time // Position the time
val remainingSpace = constraints.maxHeight - offsetY val remainingSpace = constraints.maxHeight - offsetY

View File

@@ -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()
)
}

View File

@@ -5,7 +5,7 @@ import ch.tutteli.atrium.api.fluent.en_GB.toEqual
import ch.tutteli.atrium.api.verbs.expect import ch.tutteli.atrium.api.verbs.expect
import kotlin.test.Test import kotlin.test.Test
class CluesSelectionManagerTest : SelectionManagerTest<CluesSelectionManager>() { class CluesSelectionManagerTest : SelectionManagerTest<CluesFocusable, CluesSelectionManager>() {
var primaryActionCalled: Int? = null var primaryActionCalled: Int? = null
var secondaryActionCalled: Int? = null var secondaryActionCalled: Int? = null

View File

@@ -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<FocusFollowingFocusable, FocusFollowingSelectionManager>() {
var focusables = mutableListOf<FocusFollowingFocusable>()
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)
}
}

View File

@@ -5,7 +5,7 @@ import ch.tutteli.atrium.api.fluent.en_GB.toEqual
import ch.tutteli.atrium.api.verbs.expect import ch.tutteli.atrium.api.verbs.expect
import kotlin.test.Test import kotlin.test.Test
class GridSelectionManagerTest : SelectionManagerTest<GridSelectionManager>() { class GridSelectionManagerTest : SelectionManagerTest<GridFocusable, GridSelectionManager>() {
lateinit var focusables: MutableList<GridFocusable> lateinit var focusables: MutableList<GridFocusable>

View File

@@ -5,7 +5,7 @@ import ch.tutteli.atrium.api.fluent.en_GB.toEqual
import ch.tutteli.atrium.api.verbs.expect import ch.tutteli.atrium.api.verbs.expect
import kotlin.test.Test import kotlin.test.Test
class LinearSelectionManagerTest : SelectionManagerTest<LinearSelectionManager>() { class LinearSelectionManagerTest : SelectionManagerTest<LinearFocusable, LinearSelectionManager>() {
override fun setUp() { override fun setUp() {
manager = LinearSelectionManager( manager = LinearSelectionManager(

View File

@@ -4,14 +4,60 @@ import androidx.compose.ui.InternalComposeUiApi
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType 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.BeforeTest
import kotlin.test.Test
import kotlin.test.fail
abstract class SelectionManagerTest<M : SelectionManager<*>> { abstract class SelectionManagerTest<F : Focusable<F>, M : SelectionManager<F>> {
lateinit var manager: M lateinit var manager: M
@BeforeTest @BeforeTest
abstract fun setUp() 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) @OptIn(InternalComposeUiApi::class)
fun keyEvent(key: Key, type: KeyEventType = KeyEventType.KeyUp) = KeyEvent( fun keyEvent(key: Key, type: KeyEventType = KeyEventType.KeyUp) = KeyEvent(
key = key, key = key,

View File

@@ -1,6 +1,5 @@
package ch.dissem.yaep.domain package ch.dissem.yaep.domain
@Suppress("JavaDefaultMethodsNotOverriddenByDelegation")
class GameRow<C : ItemClass<C>>( class GameRow<C : ItemClass<C>>(
val category: ItemClassCompanion<C>, val category: ItemClassCompanion<C>,
val options: List<Item<C>>, val options: List<Item<C>>,
@@ -22,12 +21,8 @@ class GameRow<C : ItemClass<C>>(
fun indexOf(element: C): Int = indexOfFirst { it.selection?.itemType == element } fun indexOf(element: C): Int = indexOfFirst { it.selection?.itemType == element }
fun cleanupOptions() { fun cleanupOptions() {
if (isSolved && all { if (isSolved && all { it.options.size == 1 && it.options.single() == it.selection })
it.solution != null return
&& it.options.size == 1
&& it.options.single() == it.selection
}
) return
do { do {
var changed = false var changed = false
val selections = filter { it.selection != null }.let { cellsWithSelection -> val selections = filter { it.selection != null }.let { cellsWithSelection ->
@@ -58,11 +53,7 @@ class GameRow<C : ItemClass<C>>(
} }
} }
} while (changed) } while (changed)
isSolved = all { isSolved = all { it.options.size == 1 && it.options.single() == it.selection }
it.solution != null
&& it.options.size == 1
&& it.options.single() == it.selection
}
} }
fun snapshot() { fun snapshot() {
@@ -72,11 +63,7 @@ class GameRow<C : ItemClass<C>>(
fun undo(): Boolean { fun undo(): Boolean {
val didChange = cells.map { it.undo() }.reduce { a, b -> a || b } val didChange = cells.map { it.undo() }.reduce { a, b -> a || b }
if (didChange) { if (didChange) {
isSolved = all { isSolved = all { it.options.size == 1 && it.options.single() == it.selection }
it.solution != null
&& it.options.size == 1
&& it.options.single() == it.selection
}
} }
return didChange return didChange
} }