Merge pull request #21: a menu tree

Solitaire, Card Games and Board Games, with Trick-taking under Card Games,
and a game listed in every menu it belongs to rather than just one.

Verified by ci.yml run 105.
This commit is contained in:
adrianoftyriel
2026-08-08 11:22:02 -06:00
committed by GitHub
3 changed files with 250 additions and 70 deletions
@@ -1,22 +1,39 @@
package org.prolibertate.games.game package org.prolibertate.games.game
/** /**
* The catalogue the main menu is built from. * The menus the main screen is built from.
* *
* Every game the app intends to ship appears here, whether or not its rules * A tree rather than a list, because "trick-taking" is a kind of card game
* engine exists yet. [GameDescriptor.available] is what the menu keys off to * rather than a rival to it. [parent] is read from a `when` rather than passed
* decide between "play" and "coming soon", so adding a game is a matter of * to the constructor: an enum entry cannot refer to another entry while the
* flipping that flag once its engine and screen land. * class is still initialising, and a lookup afterwards is free.
*/ */
enum class GameCategory(val label: String) { enum class GameMenu(val label: String, val blurb: String) {
CARD("Playing Card Games"), SOLITAIRE("Solitaire", "Played alone, against the deal or the board."),
BOARD("Board Games"), CARDS("Card Games", "Anything played with a pack."),
TRICK_TAKING("Trick-taking", "Lead, follow, and count what you took."),
BOARD("Board Games", "Boards, pieces and dice."),
;
val parent: GameMenu? get() = when (this) {
TRICK_TAKING -> CARDS
else -> null
}
val children: List<GameMenu> get() = entries.filter { it.parent == this }
val isTopLevel: Boolean get() = parent == null
} }
data class GameDescriptor( data class GameDescriptor(
val id: String, val id: String,
val title: String, val title: String,
val category: GameCategory, /**
* Every menu this game appears under. A patience is both solitaire and a
* card game, and Euchre is both a card game and a trick-taking one — so
* this is a set, and the same game is listed in each place it belongs.
*/
val menus: Set<GameMenu>,
val minPlayers: Int, val minPlayers: Int,
val maxPlayers: Int, val maxPlayers: Int,
/** True when players are grouped into partnerships rather than playing solo. */ /** True when players are grouped into partnerships rather than playing solo. */
@@ -25,6 +42,14 @@ data class GameDescriptor(
val available: Boolean, val available: Boolean,
) )
/**
* The catalogue the menus are built from.
*
* Every game the app intends to ship appears here, whether or not its rules
* engine exists yet. [GameDescriptor.available] is what the menu keys off to
* decide between "play" and "coming soon", so adding a game is a matter of
* flipping that flag once its engine and screen land.
*/
object GameCatalog { object GameCatalog {
const val EUCHRE = "euchre" const val EUCHRE = "euchre"
@@ -56,7 +81,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = EUCHRE, id = EUCHRE,
title = "Euchre", title = "Euchre",
category = GameCategory.CARD, menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
minPlayers = 4, minPlayers = 4,
maxPlayers = 4, maxPlayers = 4,
teamBased = true, teamBased = true,
@@ -66,7 +91,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = KAISER, id = KAISER,
title = "Kaiser", title = "Kaiser",
category = GameCategory.CARD, menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
minPlayers = 4, minPlayers = 4,
maxPlayers = 4, maxPlayers = 4,
teamBased = true, teamBased = true,
@@ -76,7 +101,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = PRESIDENT, id = PRESIDENT,
title = "President", title = "President",
category = GameCategory.CARD, menus = setOf(GameMenu.CARDS),
minPlayers = 3, minPlayers = 3,
maxPlayers = 7, maxPlayers = 7,
teamBased = false, teamBased = false,
@@ -86,7 +111,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = GOLF, id = GOLF,
title = "Golf", title = "Golf",
category = GameCategory.CARD, menus = setOf(GameMenu.CARDS),
minPlayers = 2, minPlayers = 2,
maxPlayers = 6, maxPlayers = 6,
teamBased = false, teamBased = false,
@@ -96,7 +121,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = WIZARD, id = WIZARD,
title = "Wizard", title = "Wizard",
category = GameCategory.CARD, menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
minPlayers = 3, minPlayers = 3,
maxPlayers = 6, maxPlayers = 6,
teamBased = false, teamBased = false,
@@ -106,7 +131,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = CRAZY_EIGHTS, id = CRAZY_EIGHTS,
title = "Crazy 8s", title = "Crazy 8s",
category = GameCategory.CARD, menus = setOf(GameMenu.CARDS),
minPlayers = 2, minPlayers = 2,
maxPlayers = 6, maxPlayers = 6,
teamBased = false, teamBased = false,
@@ -116,7 +141,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = CRIBBAGE, id = CRIBBAGE,
title = "Cribbage", title = "Cribbage",
category = GameCategory.CARD, menus = setOf(GameMenu.CARDS),
minPlayers = 2, minPlayers = 2,
maxPlayers = 4, maxPlayers = 4,
// Only at four, where the two pairs share a board between them. // Only at four, where the two pairs share a board between them.
@@ -128,7 +153,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = HEARTS, id = HEARTS,
title = "Hearts", title = "Hearts",
category = GameCategory.CARD, menus = setOf(GameMenu.CARDS, GameMenu.TRICK_TAKING),
minPlayers = 4, minPlayers = 4,
maxPlayers = 4, maxPlayers = 4,
teamBased = false, teamBased = false,
@@ -139,7 +164,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = KLONDIKE, id = KLONDIKE,
title = "Klondike", title = "Klondike",
category = GameCategory.CARD, menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
minPlayers = 1, minPlayers = 1,
maxPlayers = 1, maxPlayers = 1,
teamBased = false, teamBased = false,
@@ -150,7 +175,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = FREECELL, id = FREECELL,
title = "FreeCell", title = "FreeCell",
category = GameCategory.CARD, menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
minPlayers = 1, minPlayers = 1,
maxPlayers = 1, maxPlayers = 1,
teamBased = false, teamBased = false,
@@ -161,7 +186,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = SPIDER, id = SPIDER,
title = "Spider", title = "Spider",
category = GameCategory.CARD, menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
minPlayers = 1, minPlayers = 1,
maxPlayers = 1, maxPlayers = 1,
teamBased = false, teamBased = false,
@@ -172,7 +197,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = PYRAMID, id = PYRAMID,
title = "Pyramid", title = "Pyramid",
category = GameCategory.CARD, menus = setOf(GameMenu.SOLITAIRE, GameMenu.CARDS),
minPlayers = 1, minPlayers = 1,
maxPlayers = 1, maxPlayers = 1,
teamBased = false, teamBased = false,
@@ -183,7 +208,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = SEQUENCE, id = SEQUENCE,
title = "Sequence", title = "Sequence",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD, GameMenu.CARDS),
minPlayers = 2, minPlayers = 2,
maxPlayers = 12, maxPlayers = 12,
teamBased = true, teamBased = true,
@@ -193,7 +218,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = MASTERMIND, id = MASTERMIND,
title = "Mastermind", title = "Mastermind",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD),
minPlayers = 2, minPlayers = 2,
maxPlayers = 2, maxPlayers = 2,
teamBased = false, teamBased = false,
@@ -204,7 +229,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = BACKGAMMON, id = BACKGAMMON,
title = "Backgammon", title = "Backgammon",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD),
minPlayers = 2, minPlayers = 2,
maxPlayers = 2, maxPlayers = 2,
teamBased = false, teamBased = false,
@@ -214,7 +239,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = CHESS, id = CHESS,
title = "Chess", title = "Chess",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD),
minPlayers = 2, minPlayers = 2,
maxPlayers = 2, maxPlayers = 2,
teamBased = false, teamBased = false,
@@ -224,7 +249,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = CHECKERS, id = CHECKERS,
title = "Checkers", title = "Checkers",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD),
minPlayers = 2, minPlayers = 2,
maxPlayers = 2, maxPlayers = 2,
teamBased = false, teamBased = false,
@@ -234,7 +259,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = MORRIS, id = MORRIS,
title = "Nine Men's Morris", title = "Nine Men's Morris",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD),
minPlayers = 2, minPlayers = 2,
maxPlayers = 2, maxPlayers = 2,
teamBased = false, teamBased = false,
@@ -244,7 +269,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = PEG_SOLITAIRE, id = PEG_SOLITAIRE,
title = "Peg Solitaire", title = "Peg Solitaire",
category = GameCategory.BOARD, menus = setOf(GameMenu.SOLITAIRE, GameMenu.BOARD),
// The first game here played alone. Everything downstream reads the // The first game here played alone. Everything downstream reads the
// seat count from these two numbers, so one seat is all it takes. // seat count from these two numbers, so one seat is all it takes.
minPlayers = 1, minPlayers = 1,
@@ -257,7 +282,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = YAHTZEE, id = YAHTZEE,
title = "Yahtzee", title = "Yahtzee",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD),
minPlayers = 1, minPlayers = 1,
maxPlayers = 6, maxPlayers = 6,
teamBased = false, teamBased = false,
@@ -268,7 +293,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = PIRATES, id = PIRATES,
title = "Pirates and Bulgars", title = "Pirates and Bulgars",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD),
minPlayers = 2, minPlayers = 2,
maxPlayers = 2, maxPlayers = 2,
teamBased = false, teamBased = false,
@@ -279,7 +304,7 @@ object GameCatalog {
GameDescriptor( GameDescriptor(
id = TAYU, id = TAYU,
title = "Ta Yü", title = "Ta Yü",
category = GameCategory.BOARD, menus = setOf(GameMenu.BOARD),
minPlayers = 2, minPlayers = 2,
maxPlayers = 4, maxPlayers = 4,
// Two play solo, four play in partnerships across the two axes. // Two play solo, four play in partnerships across the two axes.
@@ -301,13 +326,24 @@ object GameCatalog {
* production build lists only what can actually be played, so nobody * production build lists only what can actually be played, so nobody
* downloads a release and taps into a dead end. * downloads a release and taps into a dead end.
*/ */
fun byCategory( fun inMenu(
category: GameCategory, menu: GameMenu,
includeComingSoon: Boolean = true, includeComingSoon: Boolean = true,
): List<GameDescriptor> = all.filter { ): List<GameDescriptor> = all.filter {
it.category == category && (includeComingSoon || it.available) it.menus.contains(menu) && (includeComingSoon || it.available)
} }
/** Everything currently playable, regardless of category. */ /**
* Whether a menu is worth showing at all.
*
* A section with nothing in it and no populated child under it is a heading
* over an empty room, which is what a production build would get if every
* game in a menu were still unfinished.
*/
fun hasAnything(menu: GameMenu, includeComingSoon: Boolean = true): Boolean =
inMenu(menu, includeComingSoon).isNotEmpty() ||
menu.children.any { inMenu(it, includeComingSoon).isNotEmpty() }
/** Everything currently playable, whatever menu it is under. */
val playable: List<GameDescriptor> get() = all.filter { it.available } val playable: List<GameDescriptor> get() = all.filter { it.available }
} }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material3.Button import androidx.compose.material3.Button
@@ -36,7 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import org.prolibertate.games.R import org.prolibertate.games.R
import org.prolibertate.games.game.GameCatalog import org.prolibertate.games.game.GameCatalog
import org.prolibertate.games.game.GameCategory import org.prolibertate.games.game.GameMenu
import org.prolibertate.games.game.GameDescriptor import org.prolibertate.games.game.GameDescriptor
import org.prolibertate.games.game.engine.TableConfig import org.prolibertate.games.game.engine.TableConfig
import org.prolibertate.games.net.LobbyController import org.prolibertate.games.net.LobbyController
@@ -64,6 +65,9 @@ class AppEnv(
*/ */
sealed interface Route { sealed interface Route {
data object Menu : Route data object Menu : Route
/** One section of the menu: its games, and any sub-section under it. */
data class Section(val menu: GameMenu) : Route
data object Settings : Route data object Settings : Route
data object Scorekeeper : Route data object Scorekeeper : Route
data class Setup(val gameId: String) : Route data class Setup(val gameId: String) : Route
@@ -152,12 +156,20 @@ private fun AppContent(
// Dev builds show the games that are not finished yet; production // Dev builds show the games that are not finished yet; production
// releases list only what can actually be played. // releases list only what can actually be played.
showComingSoon = showComingSoon, showComingSoon = showComingSoon,
onPickGame = { push(Route.Setup(it.id)) }, onPickSection = { push(Route.Section(it)) },
onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) }, onJoinGame = { push(Route.Lobby(gameId = "", optionsJson = "{}", hosting = false)) },
onScorekeeper = { push(Route.Scorekeeper) }, onScorekeeper = { push(Route.Scorekeeper) },
onSettings = { push(Route.Settings) }, onSettings = { push(Route.Settings) },
) )
is Route.Section -> SectionScreen(
menu = route.menu,
showComingSoon = showComingSoon,
onPickGame = { push(Route.Setup(it.id)) },
onPickSection = { push(Route.Section(it)) },
onBack = pop,
)
is Route.Settings -> SettingsScreen( is Route.Settings -> SettingsScreen(
env = env, env = env,
settings = settings, settings = settings,
@@ -206,7 +218,7 @@ private fun AppContent(
@Composable @Composable
fun MainMenuScreen( fun MainMenuScreen(
showComingSoon: Boolean, showComingSoon: Boolean,
onPickGame: (GameDescriptor) -> Unit, onPickSection: (GameMenu) -> Unit,
onJoinGame: () -> Unit, onJoinGame: () -> Unit,
onScorekeeper: () -> Unit, onScorekeeper: () -> Unit,
onSettings: () -> Unit, onSettings: () -> Unit,
@@ -248,28 +260,127 @@ fun MainMenuScreen(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
GameCategory.entries.forEach { category -> // Only the top of the tree. Trick-taking is reached through
val games = GameCatalog.byCategory(category, includeComingSoon = showComingSoon) // Card Games, which is where somebody looking for it would go.
// Don't leave a heading stranded over nothing. val sections = GameMenu.entries.filter {
if (games.isEmpty()) return@forEach it.isTopLevel && GameCatalog.hasAnything(it, includeComingSoon = showComingSoon)
}
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(maxLineSpan) }) { items(sections) { section ->
Text( SectionTile(
text = category.label, menu = section,
style = MaterialTheme.typography.titleMedium, count = countIn(section, showComingSoon),
fontWeight = FontWeight.Bold, onClick = { onPickSection(section) },
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp), )
)
}
items(games) { game ->
GameTile(game = game, onClick = { onPickGame(game) })
}
} }
} }
} }
} }
} }
/**
* How many games a section offers, counting whatever is under it as well.
*
* A game in both a section and its sub-section is one game, not two, so this
* counts a set rather than adding the lists together.
*/
private fun countIn(menu: GameMenu, showComingSoon: Boolean): Int =
(GameCatalog.inMenu(menu, showComingSoon) +
menu.children.flatMap { GameCatalog.inMenu(it, showComingSoon) })
.toSet().size
/**
* One section of the menu.
*
* Sub-sections come first and its own games after, so Card Games opens on
* "Trick-taking" and then everything played with a pack — including the
* trick-taking games themselves, which belong in both places and are listed in
* both. A game reachable one way and not the other would just be a game
* somebody could not find.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SectionScreen(
menu: GameMenu,
showComingSoon: Boolean,
onPickGame: (GameDescriptor) -> Unit,
onPickSection: (GameMenu) -> Unit,
onBack: () -> Unit,
) {
val children = menu.children.filter {
GameCatalog.hasAnything(it, includeComingSoon = showComingSoon)
}
val games = GameCatalog.inMenu(menu, includeComingSoon = showComingSoon)
ScreenScaffold(title = menu.label, onBack = onBack) { modifier ->
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 172.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier.fillMaxSize(),
) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = menu.blurb,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(children) { child ->
SectionTile(
menu = child,
count = countIn(child, showComingSoon),
onClick = { onPickSection(child) },
)
}
if (games.isNotEmpty() && children.isNotEmpty()) {
item(span = { GridItemSpan(maxLineSpan) }) {
Text(
text = "All ${menu.label.lowercase()}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
}
}
items(games) { game ->
GameTile(game = game, onClick = { onPickGame(game) })
}
}
}
}
/** A way into a section, rather than into a game. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun SectionTile(menu: GameMenu, count: Int, onClick: () -> Unit) {
Card(
onClick = onClick,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer,
),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = menu.label,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = menu.blurb,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(top = 4.dp),
)
Text(
text = if (count == 1) "1 game" else "$count games",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 8.dp),
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun GameTile(game: GameDescriptor, onClick: () -> Unit) { private fun GameTile(game: GameDescriptor, onClick: () -> Unit) {
@@ -16,21 +16,54 @@ class CatalogTest {
@Test @Test
fun `a production build lists only games that can be played`() { fun `a production build lists only games that can be played`() {
GameCategory.entries.forEach { category -> GameMenu.entries.forEach { menu ->
val shown = GameCatalog.byCategory(category, includeComingSoon = false) val shown = GameCatalog.inMenu(menu, includeComingSoon = false)
assertTrue( assertTrue(
"$category offers an unfinished game in a release build", "$menu offers an unfinished game in a release build",
shown.all { it.available }, shown.all { it.available },
) )
} }
} }
@Test @Test
fun `a dev build lists the whole catalogue`() { fun `every game is reachable from at least one menu`() {
val shown = GameCategory.entries.flatMap { // Counting is no good now that a game can be in several menus: the
GameCatalog.byCategory(it, includeComingSoon = true) // union is what matters, and a game in none of them would be in the
// catalogue but on no screen.
val reachable = GameMenu.entries.flatMap {
GameCatalog.inMenu(it, includeComingSoon = true)
}.toSet()
assertEquals(GameCatalog.all.toSet(), reachable)
GameCatalog.all.forEach {
assertTrue("${it.id} is in no menu", it.menus.isNotEmpty())
} }
assertEquals(GameCatalog.all.size, shown.size) }
@Test
fun `a game can be in more than one menu`() {
// The whole point of the set. A patience is solitaire and a card game;
// Euchre is a card game and a trick-taking one.
val klondike = GameCatalog.byId(GameCatalog.KLONDIKE)!!
assertTrue(klondike.menus.containsAll(setOf(GameMenu.SOLITAIRE, GameMenu.CARDS)))
assertTrue(GameCatalog.inMenu(GameMenu.SOLITAIRE).contains(klondike))
assertTrue(GameCatalog.inMenu(GameMenu.CARDS).contains(klondike))
}
@Test
fun `trick-taking sits under cards, and its games are card games too`() {
assertEquals(GameMenu.CARDS, GameMenu.TRICK_TAKING.parent)
assertEquals(listOf(GameMenu.TRICK_TAKING), GameMenu.CARDS.children)
assertTrue(GameMenu.TRICK_TAKING.isTopLevel.not())
assertTrue(GameMenu.SOLITAIRE.isTopLevel)
val tricks = GameCatalog.inMenu(GameMenu.TRICK_TAKING)
assertTrue("there should be some", tricks.isNotEmpty())
// A sub-menu that listed something its parent did not would be a game
// you could reach one way and not the other.
assertTrue(
"every trick-taking game is also a card game",
tricks.all { it.menus.contains(GameMenu.CARDS) },
)
} }
@Test @Test
@@ -42,19 +75,19 @@ class CatalogTest {
// engine is written. // engine is written.
assertTrue(GameCatalog.all.all { it.available }) assertTrue(GameCatalog.all.all { it.available })
assertEquals( assertEquals(
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false), GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true), GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
) )
} }
@Test @Test
fun `hiding unfinished games still leaves something to play in each category`() { fun `hiding unfinished games still leaves something to play in every menu`() {
// A category filtered down to nothing would leave a stranded heading, // A menu filtered down to nothing would leave a stranded heading, and a
// and a menu with no playable game at all would be a broken release. // release where a whole section was empty would be a broken one.
GameCategory.entries.forEach { category -> GameMenu.entries.forEach { menu ->
assertFalse( assertTrue(
"$category has nothing playable in a release build", "$menu has nothing playable in a release build",
GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(), GameCatalog.hasAnything(menu, includeComingSoon = false),
) )
} }
assertTrue(GameCatalog.playable.isNotEmpty()) assertTrue(GameCatalog.playable.isNotEmpty())