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>
This commit is contained in:
@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.material.icons.filled.Menu
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemColors
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -48,6 +52,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.auth.Session
|
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.api.dto.BrandDto
|
||||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.ui.auth.AccountScreen
|
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.contact.ContactScreen
|
||||||
import com.runicgateway.app.ui.home.HomeScreen
|
import com.runicgateway.app.ui.home.HomeScreen
|
||||||
import com.runicgateway.app.ui.navigation.APP_MENU
|
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.Routes
|
||||||
import com.runicgateway.app.ui.navigation.applyNavOverrides
|
import com.runicgateway.app.ui.navigation.buildNavTree
|
||||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
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.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
import com.runicgateway.app.ui.news.PostScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminContentScreen
|
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||||
@@ -150,14 +157,32 @@ fun RunicApp(
|
|||||||
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
|
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
|
||||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||||
// The admin's nav overrides, then the gates — never the other way round. An
|
// 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
|
// override is presentation only: it may relabel, reorder, group and hide, so
|
||||||
// `visibleEntries` still decides what this caller may see and remains the
|
// `pruneNav` still decides what this caller may see and remains the boundary
|
||||||
// boundary (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
|
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
|
||||||
val entries = visibleEntries(
|
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
|
||||||
applyNavOverrides(APP_MENU, appearance.navPublic),
|
isEntryVisible(it, session, shardFeatures)
|
||||||
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(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -191,19 +216,30 @@ fun RunicApp(
|
|||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
entries.forEach { entry ->
|
nav.forEach { node ->
|
||||||
NavigationDrawerItem(
|
if (node is NavNode.Section) {
|
||||||
// An admin's label wins over the bundled one, and is the
|
// A group the admin created: its label as a header, its rows
|
||||||
// same string in every locale — see MenuEntry.label.
|
// beneath it. Always open — a drawer is already a vertical
|
||||||
label = { Text(entry.label ?: stringResource(entry.labelRes)) },
|
// list, so the website's dropdown does not translate (§6.3).
|
||||||
selected = currentRoute == entry.route,
|
Text(
|
||||||
onClick = {
|
text = node.label,
|
||||||
scope.launch { drawerState.close() }
|
style = MaterialTheme.typography.labelLarge,
|
||||||
navController.navigateTopLevel(entry.route)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
},
|
modifier = Modifier.padding(
|
||||||
colors = drawerItemColors,
|
start = 28.dp,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
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))
|
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||||
@@ -302,6 +338,57 @@ 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,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||||
|
.padding(start = if (indented) 16.dp else 0.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RunicNavHost(
|
private fun RunicNavHost(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
|
|||||||
@@ -108,14 +108,26 @@ fun visibleEntries(
|
|||||||
entries: List<MenuEntry>,
|
entries: List<MenuEntry>,
|
||||||
session: Session,
|
session: Session,
|
||||||
features: ShardFeatures? = null,
|
features: ShardFeatures? = null,
|
||||||
): List<MenuEntry> =
|
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
|
||||||
entries.filter { entry ->
|
|
||||||
val allowedByRole = when (entry.access) {
|
/**
|
||||||
MenuAccess.PUBLIC -> true
|
* [visibleEntries] for a single entry — the same two filters, and the same
|
||||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
* boundary. Split out because the drawer is a tree once an admin groups rows into
|
||||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
|
||||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
* both callers must ask exactly one question or a sectioned row could be gated by
|
||||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
* a rule its top-level twin is not.
|
||||||
}
|
*/
|
||||||
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
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))
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.navigation
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
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". */
|
/** 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 label: String? = null,
|
||||||
val order: Double? = null,
|
val order: Double? = null,
|
||||||
val hidden: Boolean = false,
|
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
|
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,
|
* bare map of path → override. A bare map is unambiguous — every key is a path,
|
||||||
* so a key can never be the string `items`.
|
* 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()
|
if (navPublic == null) return emptyMap()
|
||||||
val wrapped = navPublic["items"] as? JsonObject
|
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
|
||||||
val items = wrapped ?: navPublic
|
|
||||||
return items.entries
|
return items.entries
|
||||||
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
|
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
|
||||||
.toMap()
|
.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
|
// Field by field, like every other read in M12: a bad `label` must not discard a
|
||||||
// good `order` beside it.
|
// good `order` beside it.
|
||||||
//
|
//
|
||||||
// `group` and `section` are ignored. The app renders no sections in this phase
|
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
|
||||||
// (phase 6) and never renders the admin sidebar's groups at all, and a value it
|
// never renders, and a value it cannot honor is better dropped than half-applied.
|
||||||
// cannot honor is better dropped than half-applied.
|
internal fun cleanOverride(raw: JsonObject): NavOverride {
|
||||||
private fun cleanOverride(raw: JsonObject): NavOverride {
|
|
||||||
val label = (raw["label"] as? JsonPrimitive)
|
val label = (raw["label"] as? JsonPrimitive)
|
||||||
?.takeIf { it.isString }
|
?.takeIf { it.isString }
|
||||||
?.content
|
?.content
|
||||||
@@ -72,7 +103,11 @@ private fun cleanOverride(raw: JsonObject): NavOverride {
|
|||||||
val hidden = (raw["hidden"] as? JsonPrimitive)
|
val hidden = (raw["hidden"] as? JsonPrimitive)
|
||||||
?.takeIf { !it.isString }
|
?.takeIf { !it.isString }
|
||||||
?.booleanOrNull == true
|
?.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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
|
||||||
* settings row may carry one; the root path is left alone.
|
* 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()
|
val trimmed = path?.trim().orEmpty()
|
||||||
if (trimmed.isEmpty()) return null
|
if (trimmed.isEmpty()) return null
|
||||||
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
239
app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt
Normal file
239
app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt
Normal 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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@
|
|||||||
|
|
||||||
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
||||||
<string name="nav_open_menu">Open navigation menu</string>
|
<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_home">Home</string>
|
||||||
<string name="menu_news">News</string>
|
<string name="menu_news">News</string>
|
||||||
<string name="menu_wiki">Wiki</string>
|
<string name="menu_wiki">Wiki</string>
|
||||||
|
|||||||
@@ -208,10 +208,10 @@ class NavOverridesTest {
|
|||||||
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun sectionsAndLinksAreIgnoredInThisPhase() {
|
@Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() {
|
||||||
// Phase 6 renders them. Until then their presence must not disturb the
|
// This merge is items-only; `buildNavTree` is what renders the structure
|
||||||
// items merge — and an `items` map that says nothing still returns the
|
// around them (§6.3), and it leans on this staying true — an `items` map
|
||||||
// coded menu even when sections exist beside it.
|
// that says nothing still returns the coded menu itself.
|
||||||
val stored = buildJsonObject {
|
val stored = buildJsonObject {
|
||||||
put("items", buildJsonObject { })
|
put("items", buildJsonObject { })
|
||||||
put("sections", buildJsonObject { put("id", "lore") })
|
put("sections", buildJsonObject { put("id", "lore") })
|
||||||
|
|||||||
@@ -116,4 +116,65 @@ class NavPathsTest {
|
|||||||
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
|
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
|
||||||
assertEquals(Routes.HOME, appRouteForWebPath("/"))
|
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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user