Add tests
This commit is contained in:
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user