6 Commits

Author SHA1 Message Date
c55ee7f47e Merge pull request 'feat(theme): make the app a full consumer of the shard's admin theming and nav (M12 cutover)' (#41) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 21s
SonarQube / analysis (push) Successful in 9m39s
Release APK / release (push) Successful in 10m32s
Reviewed-on: #41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:28:50 +00:00
6cbfdb1e65 Merge pull request 'fix(theme): reach Material's default arguments, and measure the theme resolvers (M12 phase 8)' (#40) from chore/m12-phase-8-coverage-and-cutover into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 11m31s
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:07:01 +00:00
b84a973559 fix(theme): let the shard's panel color and pill radius reach Material's defaults (M12 phase 8)
Two defects found on device by phase 8's AC-5 walk, both the same trap phase 2
hit with card elevation: Material takes these values as DEFAULT ARGUMENTS, not
from the theme, so mapping the token is not enough on its own.

1. Every ShardCard drew in Material's grey, not the shard's panel color.
   CardDefaults.cardColors() takes its container from surfaceContainerHighest -
   FilledCardTokens.ContainerColor, checked in the material3 1.3.0 artifact's
   bytecode - and shardColorScheme mapped surfaceContainer, High and Low but not
   Highest. All 26 ShardCard sites across 20 files were affected. Themed
   instances showed it worst: on Fantasy the page went brown and the cards
   stayed grey.

   This is NOT an M12 regression. The untouched app draws the same grey cards
   and has since M5; M12 only made it obvious by theming everything around them.
   Fixing it therefore changes the untouched app too - cards move from Material's
   grey to --panel-flat - which is the milestone's second deliberate change to a
   shard that has set nothing, alongside phase 2's card shadow. AC-1 is updated
   to record that rather than absorb it: every other role is still asserted
   byte-for-byte against the verbatim pre-M12 scheme, and the two that moved are
   named, given their new values, and checked to have actually differed before.

   surfaceContainerLowest is mapped alongside it for consistency with
   surfaceContainerLow. It has no reader in this app - the phase 8 sweep checked
   every Material component the app draws against the roles the mapping leaves at
   Material defaults, and surfaceContainerHighest was the only live one. The
   drawer scrim reads the unmapped `scrim`, which stays Material's black
   deliberately.

2. The drawer's selected row ignored --radius-pill. NavigationDrawerItem takes
   `shape` as a default argument (CircleShape); the three call sites set `colors`
   but never `shape`, so on Fantasy every other radius went square while the
   selected row stayed fully round.

Verified on device against a Fantasy-themed local instance: the three ShardCards
on the shard screen now paint --panel-flat, and the selected drawer row is the
4px rectangle the preset asks for.

477 unit tests green (476 + 1), lintDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:00:06 -05:00
c14342aa51 chore(sonar): measure the theme resolvers instead of excluding them (M12 phase 8)
sonar.coverage.exclusions carried a ui/theme/** directory glob from the M11
coverage push (COVERAGE_PLAN.md §2 phase 0). At the time that directory held
only Color.kt, Type.kt and the composables, so excluding all of it cost nothing.

M12 put three pure resolvers in it. ShardPalette, ShardStructure and
ShardTypeface are the milestone's core logic, they are the reason phases 1-3
could prove the no-op invariant as a JVM assertion, and JaCoCo on edge measures
them at 98%, 100% and 100%. The directory glob was dropping all of that out of
the denominator, so a future change that deleted those tests would not move the
coverage number at all.

The glob is now the one file it was really about: Theme.kt, the composable
(52%). The rest of ui/theme/ is measured, all of it 93% or better.

This does not rescue the gate - M12's already-measured code (data/appearance/
and ui/navigation/) covers at 93-100% and clears new_coverage >= 50 on its own.
It makes the number honest about which code the tests actually hold.

ui/components/ stays excluded as a directory: BrandAssets.kt is 11%, and the
9 tests it does have are on brandAssetUrl, the one part of it that is not a
composable body.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 10:21:18 -05:00
aeda919376 Merge pull request 'feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)' (#39) from feat/m12-phase-6-nav-sections-links into edge
Reviewed-on: #39
2026-08-08 12:59:53 +00:00
15a4d44c3f feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)
Phase 6 of M12 (docs/android/THEMING_AND_NAV.md §6.3): the drawer gains the
sections an admin grouped rows into and the links they added of their own, the
last of the public nav the website publishes.

buildNavTree ports the web's buildPublicNav and pruneNav; a link's path is
validated by the website's own read rule and resolved through resolveWebPath,
which the app has to answer for any page on the site rather than the nav's
sixteen. A link the app can open natively does; one it cannot hands off to a
Custom Tab, absolute against the configured base URL.

Phase 6 does not re-implement phase 5: with no sections and no links stored,
buildNavTree hands straight to applyNavOverrides, so an untouched instance still
gets APP_MENU back by identity and AC-1's proof is unchanged.

visibleEntries is split into isEntryVisible so pruneNav can apply the same
predicate inside a section, and drop one the gates leave empty.

476 unit tests green (442 + 34); lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:49:12 -05:00
12 changed files with 1119 additions and 54 deletions

View File

@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -48,6 +52,7 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.auth.AccountScreen
@@ -59,9 +64,11 @@ import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.applyNavOverrides
import com.runicgateway.app.ui.navigation.visibleEntries
import com.runicgateway.app.ui.navigation.buildNavTree
import com.runicgateway.app.ui.navigation.isEntryVisible
import com.runicgateway.app.ui.navigation.pruneNav
import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen
import com.runicgateway.app.ui.admin.AdminContentScreen
@@ -87,6 +94,7 @@ import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.launch
@@ -150,14 +158,32 @@ fun RunicApp(
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
// The admin's nav overrides, then the gates — never the other way round. An
// override is presentation only: it may relabel, reorder and hide, so
// `visibleEntries` still decides what this caller may see and remains the
// boundary (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val entries = visibleEntries(
applyNavOverrides(APP_MENU, appearance.navPublic),
session,
shardFeatures,
)
// override is presentation only: it may relabel, reorder, group and hide, so
// `pruneNav` still decides what this caller may see and remains the boundary
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures)
}
val context = LocalContext.current
// An added link's path is site-relative; a hand-off needs it absolute against
// the configured base URL, which is exactly what the asset resolver does (§6.3).
val resolveUrl = LocalAssetResolver.current
val openNode: (NavNode) -> Unit = { node ->
scope.launch { drawerState.close() }
when (node) {
is NavNode.Item -> navController.navigateTopLevel(node.entry.route)
// A link the app resolved opens like any other drawer row, detail screen
// or not: one rule, and back-press lands on Home as it does from every
// row. One it could not resolve goes to the browser, absolute against
// the site's base URL (§6.3).
is NavNode.Link -> node.route
?.let { navController.navigateTopLevel(it) }
?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) }
// Section headers aren't clickable — the group is always open (§6.3).
is NavNode.Section -> Unit
}
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -191,19 +217,30 @@ fun RunicApp(
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
entries.forEach { entry ->
NavigationDrawerItem(
// An admin's label wins over the bundled one, and is the
// same string in every locale — see MenuEntry.label.
label = { Text(entry.label ?: stringResource(entry.labelRes)) },
selected = currentRoute == entry.route,
onClick = {
scope.launch { drawerState.close() }
navController.navigateTopLevel(entry.route)
},
colors = drawerItemColors,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
nav.forEach { node ->
if (node is NavNode.Section) {
// A group the admin created: its label as a header, its rows
// beneath it. Always open — a drawer is already a vertical
// list, so the website's dropdown does not translate (§6.3).
Text(
text = node.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = 28.dp,
end = 28.dp,
top = 12.dp,
bottom = 4.dp,
),
)
node.items.forEach { child ->
NavRow(child, currentRoute, drawerItemColors, indented = true) {
openNode(child)
}
}
} else {
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
@@ -227,6 +264,7 @@ fun RunicApp(
}
},
colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
@@ -237,6 +275,7 @@ fun RunicApp(
onChangeServer()
},
colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
@@ -302,6 +341,62 @@ fun RunicApp(
}
}
/**
* One drawer row: a coded entry, or an admin's added link (§6.3).
*
* A link that the app can open natively is deliberately indistinguishable from a
* coded row — that is the point of resolving it. One that hands off to the browser
* carries a trailing icon, so leaving the app is never a surprise.
*/
@Composable
private fun NavRow(
node: NavNode,
currentRoute: String?,
colors: NavigationDrawerItemColors,
indented: Boolean = false,
onClick: () -> Unit,
) {
val route = when (node) {
is NavNode.Item -> node.entry.route
is NavNode.Link -> node.route
is NavNode.Section -> null
}
val label = when (node) {
// An admin's label wins over the bundled one, and is the same string in
// every locale — see MenuEntry.label.
is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes)
is NavNode.Link -> node.label
is NavNode.Section -> return
}
val handsOff = node is NavNode.Link && node.route == null
NavigationDrawerItem(
label = { Text(label) },
selected = route != null && currentRoute == route.substringBefore('?'),
onClick = onClick,
badge = if (!handsOff) {
null
} else {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
}
},
colors = colors,
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
// argument (CircleShape) rather than from the theme, so --radius-pill has to
// be handed to it at every call site or the selected row stays fully round
// while every other radius follows the shard (phase 8's AC-5 walk).
shape = LocalShardStructure.current.pill,
modifier = Modifier
.padding(NavigationDrawerItemDefaults.ItemPadding)
.padding(start = if (indented) 16.dp else 0.dp),
)
}
@Composable
private fun RunicNavHost(
navController: NavHostController,

View File

@@ -108,14 +108,26 @@ fun visibleEntries(
entries: List<MenuEntry>,
session: Session,
features: ShardFeatures? = null,
): List<MenuEntry> =
entries.filter { entry ->
val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
/**
* [visibleEntries] for a single entry — the same two filters, and the same
* boundary. Split out because the drawer is a tree once an admin groups rows into
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
* both callers must ask exactly one question or a sectioned row could be gated by
* a rule its top-level twin is not.
*/
fun isEntryVisible(
entry: MenuEntry,
session: Session,
features: ShardFeatures? = null,
): Boolean {
val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
}

View File

@@ -3,6 +3,7 @@
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
@@ -26,11 +27,24 @@ import kotlinx.serialization.json.doubleOrNull
*/
/** A usable override for one menu row. Absent fields mean "as coded". */
private data class NavOverride(
internal data class NavOverride(
val label: String? = null,
val order: Double? = null,
val hidden: Boolean = false,
/**
* The id of the section this row was dropped into, or null for a top-level
* row. Read here but honored only by the tree build (`NavTree.kt`) — the flat
* [applyNavOverrides] has nowhere to put it. Not validated against the stored
* sections here; that is the tree's job, since only it knows them.
*/
val section: String? = null,
) {
/**
* Nothing a **flat** list can express. [section] is deliberately not part of
* this: to [applyNavOverrides] a section-only override says nothing, so an
* instance that only ever grouped rows still gets its coded list back by
* identity. The tree build adds its own check.
*/
val isEmpty: Boolean get() = label == null && order == null && !hidden
}
@@ -42,24 +56,41 @@ private data class NavOverride(
* bare map of path → override. A bare map is unambiguous — every key is a path,
* so a key can never be the string `items`.
*
* `sections` and `links` are deliberately not read here; they are phase 6's.
* `sections` and `links` come out of the same wrapper, and only ever out of the
* wrapped shape — see [sectionsOf] and [linksOf].
*/
private fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
if (navPublic == null) return emptyMap()
val wrapped = navPublic["items"] as? JsonObject
val items = wrapped ?: navPublic
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
return items.entries
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
.toMap()
}
/**
* The stored value as the wrapped `{items, sections, links}` shape, or null when
* it is the bare items map phases 6-8 wrote. The discriminator is the web's: an
* `items` **object**, which a bare map can never carry because every key in one is
* a path.
*/
private fun wrapperOf(navPublic: JsonObject?): JsonObject? =
navPublic?.takeIf { it["items"] is JsonObject }
internal fun sectionsOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"sections")
internal fun linksOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"links")
private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List<JsonObject> =
(wrapperOf(navPublic)?.get(key) as? JsonArray)
?.mapNotNull { it as? JsonObject }
.orEmpty()
// Field by field, like every other read in M12: a bad `label` must not discard a
// good `order` beside it.
//
// `group` and `section` are ignored. The app renders no sections in this phase
// (phase 6) and never renders the admin sidebar's groups at all, and a value it
// cannot honor is better dropped than half-applied.
private fun cleanOverride(raw: JsonObject): NavOverride {
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
// never renders, and a value it cannot honor is better dropped than half-applied.
internal fun cleanOverride(raw: JsonObject): NavOverride {
val label = (raw["label"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
@@ -72,7 +103,11 @@ private fun cleanOverride(raw: JsonObject): NavOverride {
val hidden = (raw["hidden"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.booleanOrNull == true
return NavOverride(label = label, order = order, hidden = hidden)
val section = (raw["section"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.takeIf { it.isNotEmpty() }
return NavOverride(label = label, order = order, hidden = hidden, section = section)
}
/**

View File

@@ -113,9 +113,108 @@ internal val WEB_ROUTE_ORDER: Map<String, Int> =
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
* settings row may carry one; the root path is left alone.
*/
fun appRouteForWebPath(path: String?): String? {
fun appRouteForWebPath(path: String?): String? = WEB_PATH_TO_ROUTE[normalizeWebPath(path)]
/** `/wiki/` → `/wiki`, blank → null, and `/` left alone. */
private fun normalizeWebPath(path: String?): String? {
val trimmed = path?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
return WEB_PATH_TO_ROUTE[normalized.ifEmpty { "/" }]
return normalized.ifEmpty { "/" }
}
/**
* The website's top-level paths that are **not** CMS pages.
*
* The site serves its CMS pages from a top-level `/<slug>` (React Router ranks its
* static routes above that dynamic one), which is what lets [resolveWebPath]'s
* last rule open an admin-authored page natively. These are the segments that rule
* must not swallow: the SPA's own sections, and the two server mounts. A link to
* one of them hands off to the browser, which is where they actually live.
*/
private val RESERVED_TOP_LEVEL = setOf(
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
)
/**
* The app route an **arbitrary** website path opens, or null when the app has no
* screen for it and the link must hand off to a Custom Tab (§6.3).
*
* [appRouteForWebPath] answers for the sixteen paths the *nav* is built from; this
* answers for a path an admin typed into an added link, which may name any page on
* the site. It is the app's read of the site's own route table, and like the table
* above it is cross-repo coupling kept in one file — quoted here for the same
* reason, from `website/client/src/App.jsx`:
*
* ```jsx
* <Route path="/" element={<Portal />} />
* <Route path="/site/news" element={<News />} />
* <Route path="/site/screenshots" element={<Screenshots />} />
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
* <Route path="/site/newsletter" element={<Newsletter />} />
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
* <Route path="/site/about" element={<About />} />
* <Route path="/site/status" element={<Status />} />
* <Route path="/site/shard" element={<Shard />} />
* <Route path="/site/shard/activity" element={<ShardActivity />} />
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
* <Route path="/site/atlas" element={<Atlas />} />
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
* <Route path="/wiki" element={<Wiki />} />
* <Route path="/wiki/:slug" element={<WikiArticle />} />
* // CMS pages: top-level /:slug, matched only after the named routes above
* <Route path="/:slug" element={<CmsPage />} />
* ```
*
* Note what is *not* in it: no `/site/news/<id>` (a news item renders on its
* category page; the newsletter's is the site's one post-detail route), no
* `/page/<slug>`, and no `/contact` — the app's contact form is app-only (§6.2).
*
* ```
* / → HOME
* /site/news → NEWS
* /site/{screenshots,five-on-friday,newsletter}
* → NEWS, that category's tab
* /site/newsletter/<id> → POST (the site's one post-detail route)
* /wiki → WIKI
* /wiki/<slug> → WIKI_PAGE
* /site/<shard surface> → the mapped shard route (§6.2)
* /site/atlas/<slug> → ATLAS_CREATURE
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
* ```
*
* **A path carrying a query or a fragment hands off**, whatever its route part
* says. No app route takes either, so a native match would quietly drop what the
* admin wrote; the browser honors it exactly.
*
* Resolving a path is not the same as being allowed to see the screen behind it.
* A link to `/site/market` on a shard that does not publish the market lands on
* the Market screen's honest "not published here" state, which is what typing the
* URL on the web does too (§6.3).
*/
fun resolveWebPath(path: String?): String? {
val normalized = normalizeWebPath(path) ?: return null
if (normalized.any { it == '?' || it == '#' }) return null
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
if (!normalized.startsWith("/")) return null
// Blank segments ("/site//news") mean a malformed path, not a slug.
val segments = normalized.removePrefix("/").split('/')
if (segments.any { it.isBlank() }) return null
return when {
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
segments[0] != "site" -> null
segments.size == 3 && segments[1] == "newsletter" ->
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
Routes.marketVendor(segments[3])
else -> null
}
}

View File

@@ -0,0 +1,239 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
/**
* The drawer as a one-level tree: the coded menu, plus the **sections** an admin
* grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md
* §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`.
*
* The public nav is the one nav an admin can restructure rather than only reorder,
* and §6.1's invariant survives that structurally rather than by vigilance: a
* coded row is still keyed by a website path the app's own table declares, so an
* override still cannot invent a destination or touch a gate, while everything
* that *can* name an arbitrary path lives in [NavNode.Link], where the path rule
* is applied and the result is resolved through [resolveWebPath].
*
* An added link carries no gate and needs none — the screen behind it enforces its
* own access, so a link to somewhere this caller cannot reach lands on that
* screen's own honest state, exactly as typing the URL on the web does.
*/
sealed interface NavNode {
/** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */
data class Item(val entry: MenuEntry) : NavNode
/**
* An admin-authored link to a page on this site.
*
* @param path the stored website path, already validated — this is what a
* Custom Tab opens, resolved against the site's base URL
* @param route the app route [path] maps to, or null when the app has no
* screen for it and the link must hand off (§6.3)
*/
data class Link(
val id: String,
val label: String,
val path: String,
val route: String?,
) : NavNode
/**
* A drawer group: its [label] as a header, its [items] beneath it.
*
* The website renders these as click-to-open dropdowns; a drawer is already a
* vertical list, so the app renders the group open (§6.3). Never empty — see
* [pruneNav].
*/
data class Section(
val id: String,
val label: String,
val items: List<NavNode>,
) : NavNode
}
/** A usable `sections` entry. */
private data class SectionSpec(val id: String, val label: String, val order: Double?)
/** A usable `links` entry, with its section already checked against the stored ones. */
private data class LinkSpec(
val id: String,
val label: String,
val to: String,
val order: Double?,
val section: String?,
)
/** One node waiting to be placed: its sort key, and whether that key was stored. */
private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean)
/**
* Characters that must never appear in a stored link path. The same rule the
* website applies on read: a value that would leave the origin, or carry markup
* into a link, is dropped rather than rendered.
*/
private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""")
// Forgiving, like every other read in M12: an entry that is not usable is dropped
// and its neighbours kept. A repeated id is dropped too — the first wins, since
// the id is what a link's identity in the drawer is.
private fun readSections(raw: List<JsonObject>): List<SectionSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { section ->
val id = section.stringOrNull("id") ?: return@mapNotNull null
val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
if (!seen.add(id)) return@mapNotNull null
SectionSpec(id = id, label = label, order = section.orderOrNull())
}
}
private fun readLinks(raw: List<JsonObject>, knownSections: Set<String>): List<LinkSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { link ->
val id = link.stringOrNull("id") ?: return@mapNotNull null
val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val to = link.stringOrNull("to") ?: return@mapNotNull null
if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) {
return@mapNotNull null
}
if (!seen.add(id)) return@mapNotNull null
LinkSpec(
id = id,
label = label,
to = to,
order = link.orderOrNull(),
// A link naming a section that does not exist is a top-level link, not
// a dropped one: the admin's destination is still good.
section = link.stringOrNull("section")?.takeIf { it in knownSections },
)
}
}
private fun JsonObject.stringOrNull(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.orderOrNull(): Double? =
(this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() }
// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental
// index (the admin said "first", so first), and two explicit orders keep
// declaration order, because the sort is stable.
private fun List<Placed>.place(): List<NavNode> =
sortedWith(compareBy<Placed> { it.key }.thenByDescending { it.explicit }).map { it.node }
/**
* The coded menu with the admin's `nav_public` applied in full: relabeled,
* reordered and hidden as phase 5 already did, plus grouped into sections and
* joined by added links.
*
* With no sections and no links this **is** phase 5 — [applyNavOverrides] answers,
* so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof
* is unchanged (§2). The tree build only runs when the admin actually created
* structure.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav
*/
fun buildNavTree(base: List<MenuEntry>, navPublic: JsonObject?): List<NavNode> {
val sections = readSections(sectionsOf(navPublic))
val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet())
if (sections.isEmpty() && links.isEmpty()) {
return applyNavOverrides(base, navPublic).map { NavNode.Item(it) }
}
val knownSections = sections.map { it.id }.toSet()
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu declares — the same
// narrowing as the flat merge, so an override for a path the app maps but does
// not surface (a news category tab, a Shard hub board) is dropped here too.
val overrides = buildMap {
for ((path, raw) in itemsOf(navPublic)) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
// A section the stored value never declares is no section at all.
val section = override.section?.takeIf { it in knownSections }
if (!override.isEmpty || section != null) put(route, override.copy(section = section))
}
}
// The app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no website counterpart to be reordered against or grouped under, so they
// keep their coded order after the public block — where they already sit (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val placed = mutableListOf<Placed>()
for (entry in mapped) {
val override = overrides[entry.route]
if (override?.hidden == true) continue
placed += Placed(
node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry),
section = override?.section,
// An untouched row's key is its index in the WEBSITE's nav, so stored
// and implicit keys sit on one number line (phase 5).
key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(),
explicit = override?.order != null,
)
}
// An admin-created entity with no stored order appends after the coded rows, in
// creation order, rather than jumping to the front on a 0 default.
var next = WEBSITE_PUBLIC_NAV.size
for (section in sections) {
placed += Placed(
node = NavNode.Section(section.id, section.label, emptyList()),
section = null,
key = section.order ?: (next++).toDouble(),
explicit = section.order != null,
)
}
for (link in links) {
placed += Placed(
node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)),
section = link.section,
key = link.order ?: (next++).toDouble(),
explicit = link.order != null,
)
}
val top = placed.filter { it.node is NavNode.Section || it.section == null }.place()
return top.map { node ->
if (node !is NavNode.Section) {
node
} else {
node.copy(items = placed.filter { it.section == node.id }.place())
}
} + appOnly.map { NavNode.Item(it) }
}
/**
* The tree with this caller's gates applied — and a section they empty dropped.
*
* This is the boundary, and it runs **after** [buildNavTree], never before: an
* override is presentation, so a row it relabels, moves or marks `hidden: false`
* is still shown only if [isVisible] says so (§6.1, AC-3).
*
* The empty-section case is the one with real correctness risk and the reason the
* rule is ported rather than left to the drawer: a group whose every member is
* withheld by the caller's role or by the shard's visibility config must not draw
* as a header with nothing under it.
*
* Links are not gated — see [NavNode].
*
* @param isVisible the caller's own predicate, applied to coded items only, so
* this file stays ignorant of sessions and shard features
*/
fun pruneNav(tree: List<NavNode>, isVisible: (MenuEntry) -> Boolean): List<NavNode> {
fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry)
return tree.mapNotNull { node ->
when (node) {
is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() }
else -> node.takeIf { keep(it) }
}
}
}

View File

@@ -36,7 +36,14 @@ internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorSch
onSurfaceVariant = palette.muted,
surfaceContainer = palette.elevated,
surfaceContainerHigh = palette.elevated,
// Material's filled Card takes its container from surfaceContainerHighest —
// FilledCardTokens.ContainerColor, checked in the 1.3.0 artifact's bytecode.
// Leaving it unmapped is what made every ShardCard draw in darkColorScheme()'s
// default grey instead of --panel-flat, on themed AND untouched instances alike
// (found on device in phase 8's AC-5 walk; see "Phase 8 as landed").
surfaceContainerHighest = palette.elevated,
surfaceContainerLow = palette.surface,
surfaceContainerLowest = palette.surface,
outline = palette.outline,
outlineVariant = palette.divider,
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item

View File

@@ -37,6 +37,8 @@
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
<string name="nav_open_menu">Open navigation menu</string>
<!-- On an admin-added link the app has no screen for; it opens in a browser (§6.3). -->
<string name="nav_opens_in_browser">Opens in your browser</string>
<string name="menu_home">Home</string>
<string name="menu_news">News</string>
<string name="menu_wiki">Wiki</string>

View File

@@ -208,10 +208,10 @@ class NavOverridesTest {
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
}
@Test fun sectionsAndLinksAreIgnoredInThisPhase() {
// Phase 6 renders them. Until then their presence must not disturb the
// items merge — and an `items` map that says nothing still returns the
// coded menu even when sections exist beside it.
@Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() {
// This merge is items-only; `buildNavTree` is what renders the structure
// around them (§6.3), and it leans on this staying true — an `items` map
// that says nothing still returns the coded menu itself.
val stored = buildJsonObject {
put("items", buildJsonObject { })
put("sections", buildJsonObject { put("id", "lore") })

View File

@@ -116,4 +116,65 @@ class NavPathsTest {
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
assertEquals(Routes.HOME, appRouteForWebPath("/"))
}
// ── resolveWebPath: an added link may name any page on the site (§6.3) ──
@Test fun theNavTablesSixteenPathsResolveTheSameWay() {
// An added link to a path the nav already knows must land where the nav row
// does, or the same destination would behave differently depending on how
// the admin reached it.
for (row in WEBSITE_PUBLIC_NAV) {
assertEquals(row.route, resolveWebPath(row.path))
}
}
@Test fun theSitesDetailRoutesResolve() {
// Read off website/client/src/App.jsx. Note what is NOT here: the site has
// no /site/news/<id> route — its one post-detail route is the newsletter's.
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon"))
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C"))
assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12"))
}
@Test fun aTopLevelSlugIsACmsPage() {
// The site serves CMS pages from a top-level /<slug>, so this is the rule
// that opens an admin's own page natively rather than in a browser.
assertEquals(Routes.page("donate"), resolveWebPath("/donate"))
assertEquals(Routes.page("about"), resolveWebPath("/site/about"))
}
@Test fun theSitesOwnSectionsAreNotCmsPages() {
// React Router ranks its static routes above /:slug, and so must the app —
// otherwise a link to the admin panel would open a 404 CMS page in-app
// instead of the real thing in a browser.
for (path in listOf("/admin", "/account", "/player", "/site", "/invite", "/preview", "/api", "/uploads")) {
assertNull(path, resolveWebPath(path))
}
// /wiki is reserved from the catch-all but mapped by the table above it.
assertEquals(Routes.WIKI, resolveWebPath("/wiki"))
}
@Test fun aPathTheAppHasNoScreenForHandsOff() {
assertNull(resolveWebPath("/site/status"))
assertNull(resolveWebPath("/site/shard/activity"))
assertNull(resolveWebPath("/account/login"))
assertNull(resolveWebPath("/admin/navigation"))
assertNull(resolveWebPath("/site/atlas/dragon/extra"))
}
@Test fun aQueryOrFragmentHandsOff() {
// No app route takes either, so a native match would quietly drop what the
// admin wrote. The browser honors it exactly.
assertNull(resolveWebPath("/site/news?tag=patch"))
assertNull(resolveWebPath("/donate#tiers"))
assertEquals(Routes.NEWS, resolveWebPath("/site/news"))
}
@Test fun aMalformedPathResolvesToNothing() {
assertNull(resolveWebPath(null))
assertNull(resolveWebPath(""))
assertNull(resolveWebPath("/site//news"))
assertNull(resolveWebPath("https://elsewhere.example/donate"))
}
}

View File

@@ -0,0 +1,455 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Drawer sections and added links (THEMING_AND_NAV.md §6.3) — the tree build and
* the gate that prunes it.
*
* Three things these tests are really about. **AC-1**: an admin who created no
* structure gets phase 5 back untouched, and an untouched instance gets the coded
* [APP_MENU] entries themselves. **AC-3**: [pruneNav] runs after the build and
* remains the boundary — including inside a section, and including the case where
* it empties one. And the link path rule, which is what keeps "an override may
* never introduce navigation" true of a feature whose whole job is to add entries:
* a link may name any page **on this site**, and nothing else.
*/
class NavTreeTest {
// ── Fixtures ─────────────────────────────────────────────────────────
private fun stored(
items: JsonObject = buildJsonObject { },
sections: List<JsonObject> = emptyList(),
links: List<JsonObject> = emptyList(),
): JsonObject = buildJsonObject {
put("items", items)
put("sections", buildJsonArray { sections.forEach { add(it) } })
put("links", buildJsonArray { links.forEach { add(it) } })
}
private fun items(vararg entries: Pair<String, JsonObject>): JsonObject =
buildJsonObject { for ((path, entry) in entries) put(path, entry) }
private fun item(
label: String? = null,
order: Int? = null,
hidden: Boolean? = null,
section: String? = null,
): JsonObject = buildJsonObject {
label?.let { put("label", it) }
order?.let { put("order", it) }
hidden?.let { put("hidden", it) }
section?.let { put("section", it) }
}
private fun section(id: String, label: String? = "Lore", order: Int? = null): JsonObject =
buildJsonObject {
put("id", id)
label?.let { put("label", it) }
order?.let { put("order", it) }
}
private fun link(
id: String = "l1",
label: String? = "Donate",
to: String? = "/donate",
order: Int? = null,
section: String? = null,
): JsonObject = buildJsonObject {
put("id", id)
label?.let { put("label", it) }
to?.let { put("to", it) }
order?.let { put("order", it) }
section?.let { put("section", it) }
}
private fun tree(navPublic: JsonObject?) = buildNavTree(APP_MENU, navPublic)
/** Top-level routes, with a section standing in as `section:<id>`. */
private fun List<NavNode>.shape(): List<String> = map {
when (it) {
is NavNode.Item -> it.entry.route
is NavNode.Link -> "link:${it.id}"
is NavNode.Section -> "section:${it.id}"
}
}
private fun List<NavNode>.section(id: String): NavNode.Section =
filterIsInstance<NavNode.Section>().first { it.id == id }
private fun List<NavNode>.link(id: String): NavNode.Link =
filterIsInstance<NavNode.Link>().first { it.id == id }
// ── AC-1: no structure means phase 5, unchanged ──────────────────────
@Test fun noStoredRowIsTheCodedMenu() {
val nodes = tree(null)
assertEquals(APP_MENU.size, nodes.size)
// The entries themselves, not copies: with nothing stored, nothing was
// rebuilt to arrive at the drawer the app shipped with.
APP_MENU.forEachIndexed { index, entry ->
assertSame(entry, (nodes[index] as NavNode.Item).entry)
}
}
@Test fun withoutSectionsOrLinksTheBuildIsTheFlatMerge() {
// Phase 6 adds structure; it does not re-implement phase 5. An items-only
// row must give exactly what applyNavOverrides gives.
val row = stored(items = items("/site/about" to item(order = 0)))
assertEquals(
applyNavOverrides(APP_MENU, row).map { it.route },
tree(row).shape(),
)
}
@Test fun malformedSectionsAndLinksAreNotStructure() {
// Wrong kinds where the arrays should be — a hand-edited row, or the bare
// items map phases 6-8 stored. Neither is structure, so neither may cost
// the coded menu.
val row = buildJsonObject {
put("items", buildJsonObject { })
put("sections", buildJsonObject { put("id", "lore") })
put("links", "nope")
}
assertEquals(APP_MENU.map { it.route }, tree(row).shape())
}
// ── Sections ─────────────────────────────────────────────────────────
@Test fun aSectionCollectsItsMembersBeneathIt() {
val row = stored(
items = items(
"/wiki" to item(section = "lore"),
"/site/about" to item(section = "lore"),
),
sections = listOf(section("lore", label = " The Realm ")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI !in nodes.shape())
assertEquals("The Realm", nodes.section("lore").label)
assertEquals(
listOf(Routes.WIKI, Routes.page("about")),
nodes.section("lore").items.shape(),
)
}
@Test fun aSectionWithNoOrderAppendsAfterTheCodedRows() {
// An admin-created entity with no stored order appends in creation order
// rather than jumping to the front on a 0 default. The app's own rows stay
// behind it, where they already sit (§6.2).
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
)
val shape = tree(row).shape()
// Eight public rows are left at the top level (Wiki moved into the section),
// then the section, then the app's own rows.
assertEquals("section:lore", shape[8])
assertEquals(Routes.CONTACT, shape[9])
}
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
// Sections sort on the same number line as everything else: the website's
// sixteen indices, then admin-created entities after them.
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", order = 0)),
)
assertEquals("section:lore", tree(row).shape().first())
}
@Test fun aSectionWithoutAUsableLabelIsDroppedAndItsMembersStayPut() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", label = " ")),
)
val nodes = tree(row)
assertTrue(nodes.filterIsInstance<NavNode.Section>().isEmpty())
// The section never existed, so the reference to it is dangling and the row
// is an ordinary top-level one — not a row that vanished with its section.
assertTrue(Routes.WIKI in nodes.shape())
}
@Test fun aRepeatedSectionIdKeepsTheFirst() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", label = "First"), section("lore", label = "Second")),
)
val sections = tree(row).filterIsInstance<NavNode.Section>()
assertEquals(1, sections.size)
assertEquals("First", sections.single().label)
}
@Test fun anItemNamingAnUnknownSectionStaysTopLevel() {
val row = stored(
items = items("/wiki" to item(section = "nope")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI in nodes.shape())
assertTrue(nodes.section("lore").items.isEmpty())
}
@Test fun aHiddenItemIsDroppedEvenInsideASection() {
val row = stored(
items = items("/wiki" to item(hidden = true, section = "lore")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI !in nodes.shape())
assertTrue(nodes.section("lore").items.isEmpty())
}
@Test fun aLabelStillLandsOnASectionedRow() {
val row = stored(
items = items("/wiki" to item(label = "Codex", section = "lore")),
sections = listOf(section("lore")),
)
val wiki = tree(row).section("lore").items.filterIsInstance<NavNode.Item>().single()
assertEquals("Codex", wiki.entry.label)
// The override lands on the label and nothing else — the gates are untouched.
assertEquals(MenuAccess.PUBLIC, wiki.entry.access)
assertNull(wiki.entry.feature)
}
@Test fun aSectionRequestForARowTheDrawerDoesNotSurfaceIsIgnored() {
// Same rule as phase 5's: the app puts the hub boards behind the Shard hub
// deliberately, and grouping is no more an invitation to surface one than
// relabelling was (§6.2).
val row = stored(
items = items("/site/champs" to item(section = "lore", label = "Champs")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(nodes.section("lore").items.isEmpty())
assertTrue(Routes.SHARD_CHAMPS !in nodes.shape())
}
// ── Added links ──────────────────────────────────────────────────────
@Test fun aLinkTheAppCanResolveCarriesItsRoute() {
val row = stored(links = listOf(link(to = "/wiki/smithing")))
assertEquals(Routes.wikiPage("smithing"), tree(row).link("l1").route)
}
@Test fun aLinkTheAppCannotResolveHandsOff() {
// A null route is the Custom Tab; the path is kept verbatim so the browser
// gets exactly what the admin wrote.
val row = stored(links = listOf(link(to = "/site/status")))
val node = tree(row).link("l1")
assertNull(node.route)
assertEquals("/site/status", node.path)
}
@Test fun aLinkThatWouldLeaveTheOriginIsDropped() {
// The website's own read rule, ported: a stored value that is not a
// single-slash site path is dropped rather than rendered, so a hand-edited
// row cannot put an off-site link in the drawer.
val bad = listOf(
"//evil.example/x", "https://evil.example", "donate", "/don ate",
"/don\"ate", "/don'ate", "/don<ate", "/don\\ate",
)
for (to in bad) {
assertTrue(to, tree(stored(links = listOf(link(to = to)))).filterIsInstance<NavNode.Link>().isEmpty())
}
}
@Test fun aLinkWithoutAnIdLabelOrPathIsDropped() {
val row = stored(
links = listOf(
buildJsonObject {
put("label", "No id")
put("to", "/a")
},
link(id = "no-label", label = null),
link(id = "no-to", to = null),
link(id = "blank-label", label = " "),
link(id = "good"),
),
)
assertEquals(listOf("good"), tree(row).filterIsInstance<NavNode.Link>().map { it.id })
}
@Test fun aRepeatedLinkIdKeepsTheFirst() {
val row = stored(links = listOf(link(id = "l1", label = "First"), link(id = "l1", label = "Second")))
assertEquals("First", tree(row).link("l1").label)
}
@Test fun linksAppendAfterTheCodedRowsInCreationOrder() {
val row = stored(links = listOf(link(id = "a"), link(id = "b")))
val shape = tree(row).shape()
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1])
}
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
val row = stored(links = listOf(link(order = 0)))
assertEquals("link:l1", tree(row).shape().first())
}
@Test fun aLinkCanSitInsideASection() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
links = listOf(link(section = "lore"), link(id = "top")),
)
val nodes = tree(row)
assertEquals(listOf(Routes.WIKI, "link:l1"), nodes.section("lore").items.shape())
assertTrue("link:top" in nodes.shape())
}
@Test fun aLinkNamingAnUnknownSectionStaysTopLevel() {
// Its destination is still good; only the grouping was wrong.
val row = stored(links = listOf(link(section = "nope")))
assertTrue("link:l1" in tree(row).shape())
}
// ── AC-3: the gates run after the build, and empty a section honestly ──
private val admin = Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN))
private fun prune(nodes: List<NavNode>, session: Session, features: ShardFeatures?) =
pruneNav(nodes) { isEntryVisible(it, session, features) }
@Test fun aSectionEmptiedByTheGatesIsDropped() {
// The case the rule exists for: a group whose every member is withheld by
// the shard's visibility config must not draw as a header over nothing.
val row = stored(
items = items("/site/market" to item(section = "lore")),
sections = listOf(section("lore")),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
}
@Test fun aSectionKeepsTheMembersThisCallerMaySee() {
val row = stored(
items = items(
"/site/market" to item(section = "lore"),
"/wiki" to item(section = "lore"),
),
sections = listOf(section("lore")),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertEquals(listOf(Routes.WIKI), pruned.section("lore").items.shape())
}
@Test fun anOverrideCannotUnhideAGatedRowByGroupingIt() {
// Relabelled, moved to the front, marked `hidden: false` and tucked into a
// section of its own — and still not shown, because the shard does not
// publish the market and an admin does not outrank that.
val row = stored(
items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
sections = listOf(section("lore", order = 0)),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
assertTrue(pruned.none { it is NavNode.Item && it.entry.route == Routes.SHARD_MARKET })
}
@Test fun aSectionSurvivesOnALinkAlone() {
// Links carry no gate — the page behind one enforces its own access — so a
// section holding one is never emptied by the caller's role.
val row = stored(
items = items("/site/market" to item(section = "lore")),
sections = listOf(section("lore")),
links = listOf(link(section = "lore")),
)
val pruned = prune(tree(row), Session.SignedOut, ShardFeatures(level = "anonymous", visible = emptySet()))
assertEquals(listOf("link:l1"), pruned.section("lore").items.shape())
}
@Test fun theAppsOwnRowsAreStillGatedInTheTree() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
)
val shape = prune(tree(row), Session.SignedOut, features = null).shape()
assertTrue(Routes.ACCOUNT !in shape)
assertTrue(Routes.ADMIN_DASHBOARD !in shape)
assertTrue(Routes.PLAYER_CHARACTERS !in shape)
assertTrue(Routes.CONTACT in shape)
}
@Test fun pruningAnUntouchedTreeIsTheCodedMenusVisibleEntries() {
// The two paths through the drawer have to agree: prune(tree) for a caller
// is exactly visibleEntries of the coded menu for that caller.
val features = ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS, ShardFeature.MARKET))
assertEquals(
visibleEntries(APP_MENU, admin, features).map { it.route },
prune(tree(null), admin, features).shape(),
)
}
}

View File

@@ -57,16 +57,53 @@ class ShardColorSchemeTest {
onErrorContainer = ShardDanger,
)
/**
* The roles phase 8 deliberately moves off Material's defaults, and the values
* they move to.
*
* `surfaceContainerHighest` is the one that matters: it is
* `FilledCardTokens.ContainerColor`, so it is what every `ShardCard` paints with.
* Leaving it unmapped meant all 26 of them drew in `darkColorScheme()`'s grey
* rather than `--panel-a` — on themed instances *and* on untouched ones, which is
* why this is a visible change to the shipped app and not only a theming fix. It
* had been that way since M5; the AC-5 walk in phase 8 is what surfaced it,
* because M12 themed everything around the cards and left them behind.
*
* `surfaceContainerLowest` has no reader in this app today (the phase 8 sweep
* checked every Material component the app draws) and is mapped for consistency
* with `surfaceContainerLow`, not to fix anything.
*
* Everything else stays exactly where it was — that is what the test below is for.
*/
private val deliberatelyChanged = mapOf(
"surfaceContainerHighest" to ShardElevated,
"surfaceContainerLowest" to ShardSurface,
)
@Test
fun `the shipped palette reproduces the pre-M12 color scheme exactly`() {
assertEquals(roles(preM12Scheme), roles(shardColorScheme(ShardPalette.Shipped)))
fun `the shipped palette reproduces the pre-M12 color scheme but for the card container`() {
assertPreM12ApartFromTheCardContainer(shardColorScheme(ShardPalette.Shipped))
}
/** The same claim from the other end: an absent theme map is the shipped app. */
@Test
fun `an absent theme map reproduces the pre-M12 color scheme`() {
val resolved = shardColorScheme(ShardPalette.resolve(emptyMap()))
assertEquals(roles(preM12Scheme), roles(resolved))
assertPreM12ApartFromTheCardContainer(shardColorScheme(ShardPalette.resolve(emptyMap())))
}
/**
* Every role but [deliberatelyChanged] is byte-for-byte the pre-M12 value, and
* each of those really did move — asserting the new value alone would still pass
* if Material's default happened to equal it.
*/
private fun assertPreM12ApartFromTheCardContainer(actual: ColorScheme) {
val before = roles(preM12Scheme)
val after = roles(actual)
assertEquals(before - deliberatelyChanged.keys, after - deliberatelyChanged.keys)
for ((role, expected) in deliberatelyChanged) {
assertEquals("$role should follow the palette", expected, after[role])
assertNotEquals("$role was already the palette's value", expected, before[role])
}
}
/** Sanity: the comparison is capable of failing, and covers the whole scheme. */
@@ -97,6 +134,22 @@ class ShardColorSchemeTest {
assertEquals(ShardCta, roles["primary"]) // untouched by these two tokens
}
/**
* The phase 8 fix, stated as the thing a shard operator actually sees: set
* `--panel-flat` and the app's cards follow. This is the assertion that would have
* failed before the AC-5 walk, when `surfaceContainerHighest` — Material's filled
* `Card` container — was left at `darkColorScheme()`'s grey.
*/
@Test
fun `--panel-flat reaches the Material card container`() {
val roles = roles(shardColorScheme(ShardPalette.resolve(mapOf("--panel-flat" to "#1f160d"))))
val panel = Color(0xFF1F160D)
assertEquals(panel, roles["surfaceContainerHighest"]) // CardDefaults.cardColors()
assertEquals(panel, roles["surfaceVariant"])
assertEquals(panel, roles["surfaceContainer"])
assertEquals(panel, roles["surfaceContainerHigh"])
}
/**
* Every color role of a scheme, by name. `Color` is a value class, so the
* roles are the `long`-returning getters and their names carry Kotlin's

View File

@@ -32,10 +32,17 @@ sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/j
# tests), and Android-framework glue (Keystore-backed stores, foreground push service,
# notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and
# pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1.
#
# ui/theme/ is excluded FILE BY FILE, not as a directory. It held only constants and
# composables when COVERAGE_PLAN.md §2 phase 0 drew the list; M12 added three pure
# resolvers to it (ShardPalette, ShardStructure, ShardTypeface) which are the
# milestone's core logic and are covered 98–100%. A `ui/theme/**` glob would drop them
# out of the denominator and hide a future regression in them. Theme.kt is the one
# composable left in the directory.
sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/theme/**,\
app/src/main/java/**/ui/theme/Theme.kt,\
app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/shard/ShardComponents.kt,\