A menu tree, and games that can be in more than one branch of it

Three sections on the main screen -- Solitaire, Card Games, Board Games -- and
Trick-taking sitting under Card Games rather than beside it, because it is a
kind of card game and not a rival to one.

GameDescriptor carries a set of menus rather than one category. A patience is
solitaire and a card game; Euchre is a card game and a trick-taking one; peg
solitaire is solitaire and a board game. Each is listed in every place it
belongs, which is the point: a game reachable one way and not the other is a
game somebody cannot find.

GameMenu reads its parent from a `when` rather than taking it as a constructor
argument. An enum entry cannot refer to another entry while the class is still
initialising, and the lookup afterwards costs nothing.

Counting a section counts a set rather than adding lists together, since a game
in both Card Games and Trick-taking is one game and not two. hasAnything asks
about a menu and its children together, so a section is only hidden when there
is nothing under it anywhere -- the same rule that used to keep a heading from
being stranded over an empty category.

The dev-build "coming soon" behaviour is unchanged and still tested. What could
not survive the change is the old test that counted games across categories and
expected the total to equal the catalogue: with a game in several menus that
sum is meaningly larger. It is now a union, which is what it was really
checking -- that no game is stranded in no menu at all.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Urn6eEBA6XzrnfoibL7faC
This commit is contained in:
Claude
2026-08-08 17:16:16 +00:00
parent 459670012e
commit b12a0ed3c9
3 changed files with 250 additions and 70 deletions
@@ -16,21 +16,54 @@ class CatalogTest {
@Test
fun `a production build lists only games that can be played`() {
GameCategory.entries.forEach { category ->
val shown = GameCatalog.byCategory(category, includeComingSoon = false)
GameMenu.entries.forEach { menu ->
val shown = GameCatalog.inMenu(menu, includeComingSoon = false)
assertTrue(
"$category offers an unfinished game in a release build",
"$menu offers an unfinished game in a release build",
shown.all { it.available },
)
}
}
@Test
fun `a dev build lists the whole catalogue`() {
val shown = GameCategory.entries.flatMap {
GameCatalog.byCategory(it, includeComingSoon = true)
fun `every game is reachable from at least one menu`() {
// Counting is no good now that a game can be in several menus: the
// 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
@@ -42,19 +75,19 @@ class CatalogTest {
// engine is written.
assertTrue(GameCatalog.all.all { it.available })
assertEquals(
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false),
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true),
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = false),
GameCatalog.inMenu(GameMenu.BOARD, includeComingSoon = true),
)
}
@Test
fun `hiding unfinished games still leaves something to play in each category`() {
// A category filtered down to nothing would leave a stranded heading,
// and a menu with no playable game at all would be a broken release.
GameCategory.entries.forEach { category ->
assertFalse(
"$category has nothing playable in a release build",
GameCatalog.byCategory(category, includeComingSoon = false).isEmpty(),
fun `hiding unfinished games still leaves something to play in every menu`() {
// A menu filtered down to nothing would leave a stranded heading, and a
// release where a whole section was empty would be a broken one.
GameMenu.entries.forEach { menu ->
assertTrue(
"$menu has nothing playable in a release build",
GameCatalog.hasAnything(menu, includeComingSoon = false),
)
}
assertTrue(GameCatalog.playable.isNotEmpty())