Compare commits
10 Commits
0051e97bc7
...
v0.5.0
| Author | SHA1 | Date | |
|---|---|---|---|
| c55ee7f47e | |||
| 6cbfdb1e65 | |||
| b84a973559 | |||
| c14342aa51 | |||
| aeda919376 | |||
| 15a4d44c3f | |||
| fbe8b0bab6 | |||
| 94a5c26d6c | |||
| b95fc45548 | |||
| 3edd45d5f4 |
@@ -96,7 +96,7 @@ class MainActivity : ComponentActivity() {
|
||||
ConnectScreen(onConnected = appViewModel::onConnected)
|
||||
is AppState.Ready ->
|
||||
RunicApp(
|
||||
brand = s.appearance.brand,
|
||||
appearance = s.appearance,
|
||||
onChangeServer = appViewModel::changeServer,
|
||||
deepLinkStream = pendingStream,
|
||||
onDeepLinkConsumed = { pendingStream = null },
|
||||
|
||||
@@ -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,17 +52,23 @@ 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
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||
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.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
|
||||
@@ -84,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
|
||||
@@ -109,13 +120,14 @@ private val TOP_LEVEL_ROUTES = setOf(
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RunicApp(
|
||||
brand: BrandDto?,
|
||||
appearance: SiteAppearance,
|
||||
onChangeServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
deepLinkStream: String? = null,
|
||||
onDeepLinkConsumed: () -> Unit = {},
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
) {
|
||||
val brand = appearance.brand
|
||||
val navController = rememberNavController()
|
||||
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -141,9 +153,37 @@ fun RunicApp(
|
||||
}
|
||||
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
// A destination's route is its NavHost *pattern*, so News reports
|
||||
// "news?category={category}" (§6.2). Compare on the part before the query.
|
||||
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
|
||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||
val entries = visibleEntries(APP_MENU, session, shardFeatures)
|
||||
// The admin's nav overrides, then the gates — never the other way round. An
|
||||
// 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,
|
||||
@@ -161,6 +201,14 @@ fun RunicApp(
|
||||
// unreachable. See RunicGateway M10.
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) {
|
||||
Spacer(Modifier.height(12.dp))
|
||||
// The instance's logo above its name (§5.6). Decorative — the name
|
||||
// is the very next line — and absent on an instance that uploaded
|
||||
// none, in which case the header is exactly what it was before M12.
|
||||
BrandLogo(
|
||||
logo = brand?.logo,
|
||||
height = 32.dp,
|
||||
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp),
|
||||
)
|
||||
Text(
|
||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
@@ -169,17 +217,30 @@ fun RunicApp(
|
||||
)
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
entries.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
label = { Text(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))
|
||||
@@ -203,6 +264,7 @@ fun RunicApp(
|
||||
}
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
shape = LocalShardStructure.current.pill,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
NavigationDrawerItem(
|
||||
@@ -213,6 +275,7 @@ fun RunicApp(
|
||||
onChangeServer()
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
shape = LocalShardStructure.current.pill,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
}
|
||||
@@ -230,13 +293,24 @@ fun RunicApp(
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
title = {
|
||||
Text(
|
||||
text = (brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name)).uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val name = brand?.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.app_name)
|
||||
// The logo stands in for the title here, so unlike the drawer's
|
||||
// it is named for a screen reader — and it falls back to the
|
||||
// text when the instance has no logo or the load fails (§5.6).
|
||||
BrandLogo(
|
||||
logo = brand?.logo,
|
||||
height = 24.dp,
|
||||
contentDescription = name,
|
||||
) {
|
||||
Text(
|
||||
text = name.uppercase(),
|
||||
style = MaterialTheme.typography.titleSmall
|
||||
.copy(letterSpacing = 1.2.sp),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (isTopLevel) {
|
||||
@@ -267,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,
|
||||
@@ -284,7 +414,19 @@ private fun RunicNavHost(
|
||||
composable(Routes.HOME) {
|
||||
HomeScreen(brand = brand)
|
||||
}
|
||||
composable(Routes.NEWS) {
|
||||
// The category is optional: navigating to plain Routes.NEWS matches this
|
||||
// pattern with no argument and opens the default tab, which is every route
|
||||
// into the screen except an admin's nav override or added link (§6.2).
|
||||
composable(
|
||||
route = Routes.NEWS_ROUTE,
|
||||
arguments = listOf(
|
||||
navArgument(Routes.Args.CATEGORY) {
|
||||
type = NavType.StringType
|
||||
nullable = true
|
||||
defaultValue = null
|
||||
},
|
||||
),
|
||||
) {
|
||||
NewsScreen(onOpenPost = { category, idOrSlug ->
|
||||
navController.navigate(Routes.post(category, idOrSlug))
|
||||
})
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import com.runicgateway.app.ui.LocalAssetResolver
|
||||
|
||||
/**
|
||||
* The two brand assets an instance can upload — the logo and the hero
|
||||
* (THEMING_AND_NAV.md §5.6, M12 phase 4). Both have ridden in `BrandDto` since
|
||||
* M1 and neither has ever been drawn; the app has always spelled the instance
|
||||
* out in text wherever the website shows a mark.
|
||||
*
|
||||
* **The rule that governs this whole file: an empty slot renders nothing.** Not
|
||||
* a placeholder, not a reserved gap, not the app's own emblem — an instance
|
||||
* that has uploaded no logo must lay out exactly as it did before this phase
|
||||
* existed, which is §2 applied to assets. The website's `BrandLogo.jsx` opens
|
||||
* with the same `if (!brand.logo) return null`.
|
||||
*
|
||||
* **A failed load is an empty slot.** No broken-image icon and no retry: an
|
||||
* asset that 404s, or that can't be reached because the shard is down, must
|
||||
* degrade to the same layout as an instance that never uploaded one. That is
|
||||
* why nothing here reserves its space up front — every size modifier hangs off
|
||||
* the image itself, so when the image isn't composed neither is its padding.
|
||||
* A caller that wants space *below* a hero passes it as `Modifier.padding`
|
||||
* rather than a sibling `Spacer`, and gets both cases right for free.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Widest a logo may draw, as a multiple of its height. Mirrors the website's
|
||||
* `maxWidth: height * 6` — an operator who uploads a long wordmark gets it
|
||||
* scaled down rather than pushing the drawer header or the top bar's title out
|
||||
* of shape.
|
||||
*/
|
||||
private const val LOGO_MAX_ASPECT = 6f
|
||||
|
||||
/** The Home hero's band height (§5.6, phase 4). See [BrandHero] for why it's fixed. */
|
||||
private val HERO_HEIGHT = 180.dp
|
||||
|
||||
/**
|
||||
* The instance's uploaded logo at [height], or [fallback] when there is none.
|
||||
*
|
||||
* [fallback] defaults to drawing nothing, which is what the drawer header wants:
|
||||
* the instance name sits directly below it, so an instance with no logo simply
|
||||
* has the name where it has always been. The top bar passes the name itself,
|
||||
* because there the logo *replaces* the title — leaving that blank on a failed
|
||||
* load would strand the app in an unnamed shell until the next resume refresh,
|
||||
* and "a failed load is an empty slot" means the slot falls back to whatever
|
||||
* empty would have shown, which for the top bar is the text.
|
||||
*
|
||||
* There is deliberately no fallback while the load is still in flight. Drawing
|
||||
* the text first would flash text → logo on every navigation for the sake of
|
||||
* one frame, since Coil serves the second and later reads from its memory cache.
|
||||
*
|
||||
* Pass [contentDescription] only where the logo stands alone. Beside or above
|
||||
* the name in text it is decorative, and describing it would have a screen
|
||||
* reader say the instance's name twice — the same call the website's `alt=''`
|
||||
* makes.
|
||||
*/
|
||||
@Composable
|
||||
fun BrandLogo(
|
||||
logo: String?,
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: String? = null,
|
||||
fallback: @Composable () -> Unit = {},
|
||||
) {
|
||||
val url = brandAssetUrl(logo, LocalAssetResolver.current)
|
||||
// Keyed on the url so a refreshed appearance that swaps the logo (§5.5) gets
|
||||
// a fresh attempt rather than inheriting the old one's failure.
|
||||
var failed by remember(url) { mutableStateOf(false) }
|
||||
|
||||
if (url == null || failed) {
|
||||
fallback()
|
||||
return
|
||||
}
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = contentDescription,
|
||||
contentScale = ContentScale.Fit,
|
||||
onError = { failed = true },
|
||||
modifier = modifier
|
||||
.height(height)
|
||||
.widthIn(max = height * LOGO_MAX_ASPECT),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The instance's hero image as a full-width band above Home's title block, or
|
||||
* nothing when there is none.
|
||||
*
|
||||
* **Fixed height and cropped**, rather than the intrinsic aspect ratio the app's
|
||||
* other images (`PostScreen`, `BlockRenderer`) draw at. The website's hero is a
|
||||
* CSS background driven by `hero_layout`, which the app does not port, so the
|
||||
* app needs its own rule — and the website's *default* hero is a square emblem,
|
||||
* so an uploaded square is a case to expect rather than an edge one. At the
|
||||
* intrinsic aspect that square would be a ~360dp block that pushes the status
|
||||
* card off the first screenful; cropped to a band, a wide banner and a square
|
||||
* both give the same frame above the title.
|
||||
*
|
||||
* Clipped to `shapes.medium`, so the hero follows the shard's `--radius-card`
|
||||
* like every other surface the admin can round off (§5.2).
|
||||
*
|
||||
* Decorative: Home spells the instance's name and tagline out in text directly
|
||||
* below, so the hero carries no content description.
|
||||
*/
|
||||
@Composable
|
||||
fun BrandHero(hero: String?, modifier: Modifier = Modifier) {
|
||||
val url = brandAssetUrl(hero, LocalAssetResolver.current)
|
||||
var failed by remember(url) { mutableStateOf(false) }
|
||||
|
||||
if (url == null || failed) return
|
||||
AsyncImage(
|
||||
model = url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
onError = { failed = true },
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(HERO_HEIGHT)
|
||||
.clip(MaterialTheme.shapes.medium),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a brand asset slot to a loadable URL, or null when the slot is empty.
|
||||
*
|
||||
* The blank check has to happen on **both** sides of [resolve]: `BrandDto`
|
||||
* defaults every asset field to `""` rather than null (the server publishes the
|
||||
* empty string for "not set"), and a resolver given a path it cannot make
|
||||
* absolute may hand one straight back. Null out of here is the signal for "draw
|
||||
* nothing", so a blank slipping through would put a zero-size image request in
|
||||
* the layout instead of no image at all.
|
||||
*
|
||||
* Pulled out of the composables purely so it can be tested: the app has no
|
||||
* Robolectric, so a composable body cannot run in a JVM unit test, but this rule
|
||||
* is the whole of §5.6's "renders nothing when unset" and it is worth pinning.
|
||||
*/
|
||||
internal fun brandAssetUrl(path: String?, resolve: (String?) -> String?): String? =
|
||||
path?.takeIf { it.isNotBlank() }
|
||||
?.let(resolve)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
@@ -26,6 +26,7 @@ import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.BrandHero
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
@@ -59,6 +60,12 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(20.dp),
|
||||
) {
|
||||
// The instance's hero above the title block (§5.6) — Home is the one screen
|
||||
// with a hero-shaped space. Its bottom gap rides on the image's own modifier
|
||||
// rather than a Spacer, so an instance with no hero (or one whose hero fails
|
||||
// to load) opens on the title exactly where it has always been.
|
||||
BrandHero(hero = brand?.hero, modifier = Modifier.padding(bottom = 16.dp))
|
||||
|
||||
Text(
|
||||
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
|
||||
@@ -52,6 +52,15 @@ data class MenuEntry(
|
||||
* isn't shard-derived and only [access] applies.
|
||||
*/
|
||||
val feature: String? = null,
|
||||
/**
|
||||
* An admin's own label for this row, from the shard's `nav_public` override
|
||||
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
|
||||
*
|
||||
* A label set this way is **not localized**: it is one string for every locale,
|
||||
* which is what an admin typing a label means, and it matches the website. It
|
||||
* only ever arrives via [applyNavOverrides]; nothing in [APP_MENU] sets it.
|
||||
*/
|
||||
val label: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -99,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))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
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
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
|
||||
/**
|
||||
* Apply the admin's stored public-nav overrides to the app's coded menu
|
||||
* (THEMING_AND_NAV.md §6). The Kotlin counterpart of the website's
|
||||
* `client/src/lib/navOverrides.js`, narrowed to what a drawer can express.
|
||||
*
|
||||
* **This is presentation, never authorization.** An override carries `label`,
|
||||
* `order` and `hidden` and nothing else: it cannot introduce a route, cannot
|
||||
* touch [MenuEntry.access] or [MenuEntry.feature], and cannot un-hide anything —
|
||||
* `hidden: false` is simply the absence of hiding. [visibleEntries] therefore runs
|
||||
* **after** this merge, unchanged, and remains the actual boundary (§6.1, AC-3).
|
||||
*
|
||||
* Fail-safe throughout, matching the web: anything unrecognized — an unknown path,
|
||||
* a non-string label, a path the app doesn't surface in its menu — is ignored
|
||||
* rather than rejected, so a stale or hand-edited settings row degrades to the
|
||||
* coded menu instead of rendering a broken drawer.
|
||||
*/
|
||||
|
||||
/** A usable override for one menu row. Absent fields mean "as coded". */
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* The `items` map out of a stored `nav_public` value.
|
||||
*
|
||||
* Two shapes exist, because website phase 10 added sections and links without
|
||||
* migrating what phases 6-8 had already stored: `{items, sections, links}` and a
|
||||
* 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` come out of the same wrapper, and only ever out of the
|
||||
* wrapped shape — see [sectionsOf] and [linksOf].
|
||||
*/
|
||||
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
|
||||
if (navPublic == null) return emptyMap()
|
||||
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` 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
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val order = (raw["order"] as? JsonPrimitive)
|
||||
?.takeIf { !it.isString }
|
||||
?.doubleOrNull
|
||||
?.takeIf { it.isFinite() }
|
||||
val hidden = (raw["hidden"] as? JsonPrimitive)
|
||||
?.takeIf { !it.isString }
|
||||
?.booleanOrNull == true
|
||||
val section = (raw["section"] as? JsonPrimitive)
|
||||
?.takeIf { it.isString }
|
||||
?.content
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
return NavOverride(label = label, order = order, hidden = hidden, section = section)
|
||||
}
|
||||
|
||||
/**
|
||||
* [base] with the admin's overrides applied: rows relabeled, reordered and
|
||||
* dropped as the stored row asks.
|
||||
*
|
||||
* @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. Null, malformed, and "nothing usable in it" all return [base]
|
||||
* itself, which is what makes an untouched instance's drawer provably today's
|
||||
* (§2, AC-1).
|
||||
*/
|
||||
fun applyNavOverrides(base: List<MenuEntry>, navPublic: JsonObject?): List<MenuEntry> {
|
||||
val items = itemsOf(navPublic)
|
||||
if (items.isEmpty()) return base
|
||||
|
||||
val coded = base.map { it.route }.toSet()
|
||||
// Keyed by app route, and only for a route the coded menu actually declares.
|
||||
// This is where an override for a path the app doesn't surface in its drawer —
|
||||
// a news category tab, a Shard hub board — is dropped (§6.2). The web does the
|
||||
// same with an unknown `to`.
|
||||
val overrides = buildMap {
|
||||
for ((path, raw) in items) {
|
||||
val route = appRouteForWebPath(path) ?: continue
|
||||
if (route !in coded) continue
|
||||
val override = cleanOverride(raw)
|
||||
if (!override.isEmpty) put(route, override)
|
||||
}
|
||||
}
|
||||
if (overrides.isEmpty()) return base
|
||||
|
||||
// Rows the website's nav knows about are the ones an override can move; the
|
||||
// app's own surfaces (Contact, Account, the player groups, the staff rows)
|
||||
// have no counterpart to be reordered against and keep their coded order,
|
||||
// appended after the public block — which is exactly where they sit today, so
|
||||
// this partition is the current layout rather than a new one (§6.2).
|
||||
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
|
||||
|
||||
val sorted = mapped
|
||||
// An untouched row's sort key is its index in the WEBSITE's nav, not the
|
||||
// app's: a stored `order` is a position in that list, so both keys have to
|
||||
// sit on one number line to be comparable at all.
|
||||
//
|
||||
// Two tie-breaks, the web's: an explicit order beats a coincidental index
|
||||
// (the admin said "first", so first), and two explicit orders keep code
|
||||
// order, because the sort is stable.
|
||||
.sortedWith(
|
||||
compareBy<MenuEntry> { entry ->
|
||||
overrides[entry.route]?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble()
|
||||
}.thenByDescending { overrides[it.route]?.order != null },
|
||||
)
|
||||
|
||||
return (sorted + appOnly).mapNotNull { entry ->
|
||||
val override = overrides[entry.route] ?: return@mapNotNull entry
|
||||
when {
|
||||
override.hidden -> null
|
||||
override.label != null -> entry.copy(label = override.label)
|
||||
else -> entry
|
||||
}
|
||||
}
|
||||
}
|
||||
220
app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt
Normal file
220
app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt
Normal file
@@ -0,0 +1,220 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
|
||||
/**
|
||||
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
||||
*
|
||||
* The public nav an admin edits is keyed by **website** paths, so honoring it in
|
||||
* the app needs a translation. This is the one new piece of cross-repo coupling
|
||||
* the milestone introduces, which is why it lives in a single file with the
|
||||
* website's own array quoted right beside it — the coupling is visible and
|
||||
* reviewable in one place rather than spread across the drawer's call sites.
|
||||
*
|
||||
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
|
||||
* exported owner of the list (`export const NAV`, and Admin → Navigation edits
|
||||
* exactly it):
|
||||
*
|
||||
* ```js
|
||||
* export const NAV = [
|
||||
* { label: 'Home', to: '/', end: true },
|
||||
* { label: 'News', to: '/site/news' },
|
||||
* { label: 'Screenshots', to: '/site/screenshots' },
|
||||
* { label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
* { label: 'Newsletter', to: '/site/newsletter' },
|
||||
* { label: 'Wiki', to: '/wiki' },
|
||||
* { label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
||||
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||
* { label: 'Market', to: '/site/market', feature: 'market' },
|
||||
* { label: 'About', to: '/site/about' },
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
|
||||
* app's own source of truth for gating, and a second copy of a security-relevant
|
||||
* value that drifts silently is worth more than it costs. This table carries the
|
||||
* mapping and nothing else.
|
||||
*
|
||||
* Not every row maps to something the app shows in its drawer, and that is the
|
||||
* design rather than an omission — see [WEB_PATH_TO_ROUTE].
|
||||
*/
|
||||
|
||||
/** One row of the website's public nav: its path, and the app route it opens. */
|
||||
data class WebNavPath(val path: String, val route: String)
|
||||
|
||||
/**
|
||||
* The website's public nav in **its** order, mapped to app routes.
|
||||
*
|
||||
* The order is load-bearing, not decorative: a stored `order` is an index into
|
||||
* *this* list (the admin's editor writes the position a row holds on the web), so
|
||||
* a row the admin never moved has to take its key from the same number line or
|
||||
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
|
||||
*/
|
||||
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
|
||||
WebNavPath("/", Routes.HOME),
|
||||
WebNavPath("/site/news", Routes.NEWS),
|
||||
// The app's News screen carries all four categories as tabs, so these three
|
||||
// have a route but no drawer row of their own — see the note below.
|
||||
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
|
||||
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
|
||||
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
|
||||
WebNavPath("/wiki", Routes.WIKI),
|
||||
WebNavPath("/site/shard", Routes.SHARD),
|
||||
// Behind the Shard hub in the app, deliberately — no drawer row either.
|
||||
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
|
||||
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
|
||||
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
|
||||
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
|
||||
WebNavPath("/site/rules", Routes.SHARD_RULES),
|
||||
WebNavPath("/site/atlas", Routes.ATLAS),
|
||||
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||
WebNavPath("/site/market", Routes.SHARD_MARKET),
|
||||
WebNavPath("/site/about", Routes.page("about")),
|
||||
)
|
||||
|
||||
/**
|
||||
* The same table as a lookup.
|
||||
*
|
||||
* **A mapped route is not the same thing as a drawer row.** Seven of these paths
|
||||
* resolve to a screen the app reaches some other way: the three news categories
|
||||
* are tabs on one News screen, and champs / guilds / governors / houses sit behind
|
||||
* the Shard hub because that is the better shape on a phone. An override for one
|
||||
* of them is **ignored** — §6.1's rule is that a nav override may never introduce
|
||||
* navigation, and the hub is a design decision, not an accident to correct. The
|
||||
* merge enforces that by intersecting with [APP_MENU]; nothing here needs to know
|
||||
* which rows those are.
|
||||
*
|
||||
* The mapping still exists for all sixteen because phase 6's added links resolve
|
||||
* an admin-authored path against the same table, and *there* a category tab or a
|
||||
* hub board is a perfectly good destination — the admin asked for it by path.
|
||||
*/
|
||||
val WEB_PATH_TO_ROUTE: Map<String, String> =
|
||||
WEBSITE_PUBLIC_NAV.associate { it.path to it.route }
|
||||
|
||||
/**
|
||||
* Each app route's index in the website's own nav order — the sort key a row the
|
||||
* admin never moved takes, so it lands on the same number line as a stored
|
||||
* `order`. All sixteen routes are distinct, so this loses nothing.
|
||||
*/
|
||||
internal val WEB_ROUTE_ORDER: Map<String, Int> =
|
||||
WEBSITE_PUBLIC_NAV.withIndex().associate { (index, row) -> row.route to index }
|
||||
|
||||
/**
|
||||
* The app route a website nav path opens, or null when the app has no screen for
|
||||
* 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? = 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 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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
|
||||
/**
|
||||
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
|
||||
* plain strings for Navigation-Compose; argument-bearing routes expose a
|
||||
@@ -14,6 +16,19 @@ object Routes {
|
||||
const val WIKI = "wiki"
|
||||
const val CONTACT = "contact"
|
||||
|
||||
/**
|
||||
* The News hub's NavHost pattern: [NEWS] plus an optional category, so a link
|
||||
* to one of the website's three category pages can land on the matching tab
|
||||
* (THEMING_AND_NAV.md §6.2). Navigating to plain [NEWS] matches this pattern
|
||||
* with no argument and opens the default tab, so every existing call site —
|
||||
* the drawer, [forStream] — is unaffected.
|
||||
*
|
||||
* Declared beside [NEWS] rather than replacing it because the two are used for
|
||||
* different things: this is what `composable()` and `destination.route` speak,
|
||||
* [NEWS] is what callers navigate to.
|
||||
*/
|
||||
const val NEWS_ROUTE = "news?category={category}"
|
||||
|
||||
/** Native login (§4.1) and the signed-in account surface (§5). */
|
||||
const val LOGIN = "login"
|
||||
const val ACCOUNT = "account"
|
||||
@@ -79,6 +94,13 @@ object Routes {
|
||||
|
||||
fun page(slug: String) = "page/$slug"
|
||||
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
|
||||
|
||||
/**
|
||||
* The News hub with [category] preselected. Takes the enum rather than a slug
|
||||
* so an unmapped category cannot reach the NavHost — the screen's tabs are the
|
||||
* enum's entries, and a slug it doesn't know would select nothing.
|
||||
*/
|
||||
fun news(category: ContentRepository.PostCategory) = "news?category=${category.urlSlug}"
|
||||
fun wikiPage(slug: String) = "wiki/$slug"
|
||||
|
||||
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.news
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.repository.ContentRepository
|
||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -21,9 +23,15 @@ import javax.inject.Inject
|
||||
@HiltViewModel
|
||||
class NewsViewModel @Inject constructor(
|
||||
private val contentRepository: ContentRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _category = MutableStateFlow(PostCategory.NEWS)
|
||||
// Which tab to open on. Absent — every route into this screen except an
|
||||
// admin's nav override or added link (THEMING_AND_NAV.md §6.2) — is the
|
||||
// default feed, and so is a slug the app doesn't know.
|
||||
private val _category = MutableStateFlow(
|
||||
PostCategory.fromUrlSlug(savedStateHandle[Routes.Args.CATEGORY]) ?: PostCategory.NEWS,
|
||||
)
|
||||
val category: StateFlow<PostCategory> = _category.asStateFlow()
|
||||
|
||||
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -38,15 +38,22 @@ class ContentViewModelTest {
|
||||
private val settings = SettingsRepository(api)
|
||||
|
||||
// ── News hub ──────────────────────────────────────────────────────────
|
||||
/** No category argument: how every route into the hub but §6.2's arrives. */
|
||||
private fun newsViewModel(category: String? = null) =
|
||||
NewsViewModel(
|
||||
content,
|
||||
SavedStateHandle(category?.let { mapOf(Routes.Args.CATEGORY to it) } ?: emptyMap()),
|
||||
)
|
||||
|
||||
@Test fun newsLoadsSelectedCategory() {
|
||||
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
|
||||
val vm = NewsViewModel(content)
|
||||
val vm = newsViewModel()
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
assertEquals(1, (vm.state.value as UiState.Success).data.size)
|
||||
}
|
||||
|
||||
@Test fun newsSelectCategoryReloads() {
|
||||
val vm = NewsViewModel(content)
|
||||
val vm = newsViewModel()
|
||||
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
|
||||
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
|
||||
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
|
||||
@@ -55,7 +62,20 @@ class ContentViewModelTest {
|
||||
|
||||
@Test fun newsServerErrorIsUiError() {
|
||||
api.error = httpError(500)
|
||||
assertTrue(NewsViewModel(content).state.value is UiState.Error)
|
||||
assertTrue(newsViewModel().state.value is UiState.Error)
|
||||
}
|
||||
|
||||
@Test fun newsOpensOnTheCategoryTheRouteAsksFor() {
|
||||
// The app's half of an admin's nav override or added link pointing at one of
|
||||
// the website's three category pages (THEMING_AND_NAV.md §6.2).
|
||||
val vm = newsViewModel("five-on-friday")
|
||||
assertEquals(ContentRepository.PostCategory.FIVE_ON_FRIDAY, vm.category.value)
|
||||
}
|
||||
|
||||
@Test fun newsFallsBackToTheDefaultFeedForAnUnknownCategory() {
|
||||
// A hand-edited settings row, or a category the site has and the app doesn't.
|
||||
assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel("bogus").category.value)
|
||||
assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel().category.value)
|
||||
}
|
||||
|
||||
// ── Post detail (SavedStateHandle args) ─────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.components
|
||||
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* §5.6's one testable rule: **an empty slot resolves to nothing.** The drawing
|
||||
* itself is out of reach here — the app carries no Robolectric, so a composable
|
||||
* body cannot run in a JVM test and phase 4's layout is AC-5's job — but the
|
||||
* decision of whether to draw at all is pure, and it is the decision that keeps
|
||||
* an unbranded instance laying out as it did before M12.
|
||||
*
|
||||
* The resolver is faked as the absolute-URL join the real one performs
|
||||
* (`AppViewModel.resolveAsset`, unchanged by this phase), so these assert
|
||||
* [brandAssetUrl]'s own contract rather than re-testing the network layer.
|
||||
*/
|
||||
class BrandAssetsTest {
|
||||
|
||||
private val resolve: (String?) -> String? = { path ->
|
||||
when {
|
||||
path.isNullOrBlank() -> null
|
||||
path.startsWith("http") -> path
|
||||
else -> "https://shard.example${if (path.startsWith("/")) "" else "/"}$path"
|
||||
}
|
||||
}
|
||||
|
||||
// --- the empty slot: every shape "not set" arrives in ------------------
|
||||
|
||||
@Test
|
||||
fun `a null slot resolves to nothing`() {
|
||||
assertNull(brandAssetUrl(null, resolve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty slot resolves to nothing`() {
|
||||
// The server publishes "" for an asset that was never uploaded, and BrandDto
|
||||
// defaults to it — this is the case that carries the untouched instance.
|
||||
assertNull(brandAssetUrl("", resolve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a whitespace-only slot resolves to nothing`() {
|
||||
assertNull(brandAssetUrl(" ", resolve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the shipped brand has neither a logo nor a hero`() {
|
||||
// AC-1 for phase 4: nothing about a default BrandDto puts an image on screen.
|
||||
val brand = BrandDto()
|
||||
assertNull(brandAssetUrl(brand.logo, resolve))
|
||||
assertNull(brandAssetUrl(brand.hero, resolve))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed settings load leaves no brand to draw`() {
|
||||
// SiteAppearance.NONE is what a dead backend produces (§2). It has no brand
|
||||
// at all, so both slots are absent rather than empty.
|
||||
val brand: BrandDto? = SiteAppearance.NONE.brand
|
||||
assertNull(brand)
|
||||
assertNull(brandAssetUrl(brand?.logo, resolve))
|
||||
assertNull(brandAssetUrl(brand?.hero, resolve))
|
||||
}
|
||||
|
||||
// --- the filled slot ---------------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a site-relative upload resolves against the shard's base`() {
|
||||
assertEquals(
|
||||
"https://shard.example/uploads/brand/logo.png",
|
||||
brandAssetUrl("/uploads/brand/logo.png", resolve),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an absolute URL passes through`() {
|
||||
// BRAND_LOGO may be set to an off-site URL; the resolver leaves those alone.
|
||||
assertEquals(
|
||||
"https://cdn.example/logo.svg",
|
||||
brandAssetUrl("https://cdn.example/logo.svg", resolve),
|
||||
)
|
||||
}
|
||||
|
||||
// --- the second blank check -------------------------------------------
|
||||
|
||||
@Test
|
||||
fun `a resolver that returns nothing resolves to nothing`() {
|
||||
// No base URL configured yet: the real resolver hands the path back or gives
|
||||
// up. Either way the slot must not become an image request.
|
||||
assertNull(brandAssetUrl("/uploads/brand/logo.png") { null })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a resolver that returns blank resolves to nothing`() {
|
||||
// Why the blank check is on both sides of the resolver, not just the input.
|
||||
assertNull(brandAssetUrl("/uploads/brand/logo.png") { "" })
|
||||
assertNull(brandAssetUrl("/uploads/brand/logo.png") { " " })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* 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.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
|
||||
|
||||
/**
|
||||
* The public-nav override merge (THEMING_AND_NAV.md §6): label, order and hidden,
|
||||
* applied to the coded [APP_MENU] and nothing else.
|
||||
*
|
||||
* Two things these tests are really about. **AC-1** — an instance whose admin never
|
||||
* touched the nav must get the drawer the app shipped with, which here is the
|
||||
* strongest possible assertion: the same list instance back. And **AC-3** — the
|
||||
* merge runs before [visibleEntries] and cannot reach past it, so a `hidden: false`
|
||||
* on a gated row still shows nothing.
|
||||
*/
|
||||
class NavOverridesTest {
|
||||
|
||||
private fun nav(vararg items: Pair<String, JsonObject>): JsonObject =
|
||||
buildJsonObject { for ((path, entry) in items) put(path, entry) }
|
||||
|
||||
private fun entry(
|
||||
label: String? = null,
|
||||
order: Int? = null,
|
||||
hidden: Boolean? = null,
|
||||
): JsonObject = buildJsonObject {
|
||||
label?.let { put("label", it) }
|
||||
order?.let { put("order", it) }
|
||||
hidden?.let { put("hidden", it) }
|
||||
}
|
||||
|
||||
private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route }
|
||||
|
||||
/** The public block's routes, in coded order — the first nine of APP_MENU. */
|
||||
private val codedPublic = listOf(
|
||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
|
||||
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"),
|
||||
)
|
||||
|
||||
// ── AC-1: the untouched instance ─────────────────────────────────────
|
||||
|
||||
@Test fun noStoredRowReturnsTheCodedMenuItself() {
|
||||
// Identity, not equality: the drawer of an instance that never edited its
|
||||
// nav is the shipped one, and nothing was rebuilt to arrive at it.
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, null))
|
||||
}
|
||||
|
||||
@Test fun anEmptyRowReturnsTheCodedMenuItself() {
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, buildJsonObject { }))
|
||||
}
|
||||
|
||||
@Test fun aRowWithNothingUsableInItReturnsTheCodedMenuItself() {
|
||||
// A blank label, a non-finite order, `hidden: false`, a path the app has no
|
||||
// screen for, and a path it maps but doesn't put in the drawer. None of it
|
||||
// says anything, so none of it may cost the coded menu.
|
||||
val stored = nav(
|
||||
"/" to entry(label = " "),
|
||||
"/site/news" to entry(hidden = false),
|
||||
"/admin/appearance" to entry(label = "Nope"),
|
||||
"/site/screenshots" to entry(label = "Shots", order = 0),
|
||||
"/site/champs" to entry(hidden = true),
|
||||
)
|
||||
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
|
||||
}
|
||||
|
||||
@Test fun aMalformedEntryIsDroppedAndItsNeighbourKept() {
|
||||
val stored = buildJsonObject {
|
||||
put("/site/news", "not an object")
|
||||
put("/wiki", entry(label = "Codex"))
|
||||
}
|
||||
|
||||
val merged = applyNavOverrides(APP_MENU, stored)
|
||||
|
||||
assertEquals(codedPublic, merged.take(9).map { it.route })
|
||||
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
||||
assertNull(merged.first { it.route == Routes.NEWS }.label)
|
||||
}
|
||||
|
||||
// ── Labels ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun aLabelOverridesTheBundledString() {
|
||||
val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm ")))
|
||||
|
||||
val shard = merged.first { it.route == Routes.SHARD }
|
||||
assertEquals("The Realm", shard.label)
|
||||
// The override lands on `label` and nothing else — the gates are untouched.
|
||||
assertEquals(ShardFeature.STATUS, shard.feature)
|
||||
assertEquals(MenuAccess.PUBLIC, shard.access)
|
||||
assertEquals(codedPublic, merged.take(9).map { it.route })
|
||||
}
|
||||
|
||||
@Test fun aNonStringLabelIsIgnored() {
|
||||
val stored = buildJsonObject { put("/wiki", buildJsonObject { put("label", 7) }) }
|
||||
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
|
||||
}
|
||||
|
||||
// ── Hidden ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun hiddenDropsTheRow() {
|
||||
val routes = routes(nav("/site/market" to entry(hidden = true)))
|
||||
|
||||
assertTrue(Routes.SHARD_MARKET !in routes)
|
||||
assertEquals(APP_MENU.size - 1, routes.size)
|
||||
}
|
||||
|
||||
@Test fun homeCanBeHidden() {
|
||||
// Mirrors the website, where `/` is hideable too. Home stays the NavHost's
|
||||
// start destination and stays reachable by back-press; the app does not
|
||||
// invent a policy the site doesn't have.
|
||||
val routes = routes(nav("/" to entry(hidden = true)))
|
||||
|
||||
assertTrue(Routes.HOME !in routes)
|
||||
}
|
||||
|
||||
@Test fun hiddenFalseHidesNothing() {
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false))))
|
||||
}
|
||||
|
||||
@Test fun hiddenWinsOverALabelOnTheSameRow() {
|
||||
val routes = routes(nav("/wiki" to entry(label = "Codex", hidden = true)))
|
||||
|
||||
assertTrue(Routes.WIKI !in routes)
|
||||
}
|
||||
|
||||
// ── Order ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
|
||||
// The website's own indices: About is 15 and Home is 0, so swapping them
|
||||
// is what an admin dragging About to the top writes.
|
||||
val routes = routes(
|
||||
nav(
|
||||
"/site/about" to entry(order = 0),
|
||||
"/" to entry(order = 15),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
|
||||
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME,
|
||||
),
|
||||
routes.take(9),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
|
||||
// The tie-break that needs the website's order rather than the app's: an
|
||||
// explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where
|
||||
// the three news categories sit between News and Wiki). Explicit wins.
|
||||
val routes = routes(nav("/site/about" to entry(order = 5)))
|
||||
|
||||
assertEquals(
|
||||
listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI),
|
||||
routes.take(4),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
|
||||
// Contact, Account, Notifications, the three player groups and the four
|
||||
// staff rows have no website counterpart to be reordered against (§6.2).
|
||||
val tail = APP_MENU.drop(9).map { it.route }
|
||||
|
||||
val merged = routes(nav("/site/about" to entry(order = 0)))
|
||||
|
||||
assertEquals(tail, merged.drop(9))
|
||||
}
|
||||
|
||||
@Test fun reorderingAndHidingCompose() {
|
||||
val routes = routes(
|
||||
nav(
|
||||
"/site/about" to entry(order = 0),
|
||||
"/" to entry(hidden = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(Routes.page("about"), routes.first())
|
||||
assertTrue(Routes.HOME !in routes)
|
||||
}
|
||||
|
||||
// ── The two stored shapes ────────────────────────────────────────────
|
||||
|
||||
@Test fun theWrappedShapeIsRead() {
|
||||
// Website phase 10 wraps the map as {items, sections, links} without
|
||||
// migrating what phases 6-8 stored bare, so both shapes are live.
|
||||
val stored = buildJsonObject {
|
||||
put("items", nav("/wiki" to entry(label = "Codex")))
|
||||
put("sections", buildJsonObject { })
|
||||
put("links", buildJsonObject { })
|
||||
}
|
||||
|
||||
val merged = applyNavOverrides(APP_MENU, stored)
|
||||
|
||||
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
||||
}
|
||||
|
||||
@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") })
|
||||
}
|
||||
|
||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
|
||||
}
|
||||
|
||||
// ── AC-3: the merge cannot reach past the gates ──────────────────────
|
||||
|
||||
@Test fun anOverrideCannotUnhideAFeatureGatedRow() {
|
||||
val stored = nav(
|
||||
"/site/market" to entry(label = "Bazaar", hidden = false, order = 0),
|
||||
)
|
||||
|
||||
val visible = visibleEntries(
|
||||
applyNavOverrides(APP_MENU, stored),
|
||||
Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN)),
|
||||
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
|
||||
).map { it.route }
|
||||
|
||||
// Relabeled and moved to the front, and still not shown: the shard does not
|
||||
// publish the market, and an admin does not outrank that.
|
||||
assertTrue(Routes.SHARD_MARKET !in visible)
|
||||
assertTrue(Routes.SHARD in visible)
|
||||
}
|
||||
|
||||
@Test fun anOverrideCannotUnhideARoleGatedRow() {
|
||||
val stored = nav("/" to entry(order = 99))
|
||||
|
||||
val visible = visibleEntries(
|
||||
applyNavOverrides(APP_MENU, stored),
|
||||
Session.SignedOut,
|
||||
features = null,
|
||||
).map { it.route }
|
||||
|
||||
assertTrue(Routes.ACCOUNT !in visible)
|
||||
assertTrue(Routes.ADMIN_DASHBOARD !in visible)
|
||||
assertTrue(Routes.PLAYER_CHARACTERS !in visible)
|
||||
}
|
||||
|
||||
@Test fun theGatesRunOnTheMergedListNotTheCodedOne() {
|
||||
// Hiding is subtractive on top of the gates, so the two compose: the row an
|
||||
// admin hid is gone, and so is the row this caller may not see.
|
||||
val stored = nav("/wiki" to entry(hidden = true))
|
||||
|
||||
val visible = visibleEntries(
|
||||
applyNavOverrides(APP_MENU, stored),
|
||||
Session.SignedOut,
|
||||
ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS)),
|
||||
).map { it.route }
|
||||
|
||||
assertTrue(Routes.WIKI !in visible)
|
||||
assertTrue(Routes.SHARD_MARKET !in visible)
|
||||
assertTrue(Routes.HOME in visible)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The website path → app route table (THEMING_AND_NAV.md §6.2).
|
||||
*
|
||||
* This is the milestone's one piece of cross-repo coupling, so the tests are
|
||||
* mostly about the table's *shape* — that it stays complete, unambiguous, and
|
||||
* honest about which rows the app actually surfaces in its drawer.
|
||||
*/
|
||||
class NavPathsTest {
|
||||
|
||||
@Test fun everyWebsiteNavPathIsMapped() {
|
||||
// The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the
|
||||
// site adds one, this is the test that says so — a path with no mapping is
|
||||
// silently unresolvable in phase 6's link handling.
|
||||
assertEquals(16, WEBSITE_PUBLIC_NAV.size)
|
||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
|
||||
}
|
||||
|
||||
@Test fun everyMappedRouteIsDistinct() {
|
||||
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
|
||||
// row's position from the sort.
|
||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEBSITE_PUBLIC_NAV.map { it.route }.toSet().size)
|
||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_ROUTE_ORDER.size)
|
||||
}
|
||||
|
||||
@Test fun theWebsitesOrderIsPreserved() {
|
||||
// Load-bearing: a stored `order` is an index into this list.
|
||||
assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME])
|
||||
assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS])
|
||||
assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI])
|
||||
assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")])
|
||||
}
|
||||
|
||||
@Test fun theNineDrawerRowsAreTheIntersectionWithAppMenu() {
|
||||
// Nine of the sixteen have a drawer row. The other seven are mapped but not
|
||||
// surfaced — three news category tabs and the four Shard hub boards — and
|
||||
// an override for one of them is ignored rather than obeyed (§6.2).
|
||||
val coded = APP_MENU.map { it.route }.toSet()
|
||||
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"/", "/site/news", "/wiki", "/site/shard", "/site/rules",
|
||||
"/site/atlas", "/site/leaderboards", "/site/market", "/site/about",
|
||||
),
|
||||
surfaced,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun theSevenUnsurfacedPathsStillResolveToAScreen() {
|
||||
// Phase 6's added links resolve against the same table, and there a category
|
||||
// tab or a hub board is a perfectly good destination.
|
||||
val unsurfaced = listOf(
|
||||
"/site/screenshots", "/site/five-on-friday", "/site/newsletter",
|
||||
"/site/champs", "/site/guilds", "/site/governors", "/site/houses",
|
||||
)
|
||||
|
||||
assertTrue(unsurfaced.all { appRouteForWebPath(it) != null })
|
||||
assertTrue(unsurfaced.none { appRouteForWebPath(it) in APP_MENU.map { e -> e.route } })
|
||||
}
|
||||
|
||||
@Test fun theNewsCategoriesMapToTheirTab() {
|
||||
assertEquals("news?category=screenshots", appRouteForWebPath("/site/screenshots"))
|
||||
assertEquals("news?category=five-on-friday", appRouteForWebPath("/site/five-on-friday"))
|
||||
assertEquals("news?category=newsletter", appRouteForWebPath("/site/newsletter"))
|
||||
// The plain news path is the un-argumented route, so it matches the drawer's
|
||||
// coded row and opens the default tab.
|
||||
assertEquals(Routes.NEWS, appRouteForWebPath("/site/news"))
|
||||
}
|
||||
|
||||
@Test fun theCategoryRouteMatchesTheNavHostPattern() {
|
||||
// The pattern the NavHost declares and the value callers navigate to have to
|
||||
// agree on the query key, or the argument arrives as null and the screen
|
||||
// silently opens the default tab.
|
||||
assertEquals("news?category={category}", Routes.NEWS_ROUTE)
|
||||
assertTrue(Routes.NEWS_ROUTE.startsWith("${Routes.NEWS}?"))
|
||||
for (category in PostCategory.entries) {
|
||||
assertEquals("${Routes.NEWS}?category=${category.urlSlug}", Routes.news(category))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun theRoutePatternStripsToTheTopLevelRoute() {
|
||||
// How RunicApp recognizes the News destination: `destination.route` is the
|
||||
// pattern, and the drawer's row is the bare route.
|
||||
assertEquals(Routes.NEWS, Routes.NEWS_ROUTE.substringBefore('?'))
|
||||
assertEquals(Routes.NEWS, Routes.news(PostCategory.NEWSLETTER).substringBefore('?'))
|
||||
}
|
||||
|
||||
// ── Lookup hygiene ───────────────────────────────────────────────────
|
||||
|
||||
@Test fun anUnknownPathResolvesToNothing() {
|
||||
assertNull(appRouteForWebPath("/admin/appearance"))
|
||||
assertNull(appRouteForWebPath("/site/news/some-post"))
|
||||
assertNull(appRouteForWebPath("https://elsewhere.example/"))
|
||||
}
|
||||
|
||||
@Test fun blankAndNullResolveToNothing() {
|
||||
assertNull(appRouteForWebPath(null))
|
||||
assertNull(appRouteForWebPath(""))
|
||||
assertNull(appRouteForWebPath(" "))
|
||||
}
|
||||
|
||||
@Test fun aTrailingSlashIsTolerated() {
|
||||
// A hand-edited settings row may carry one; the root is left alone.
|
||||
assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/"))
|
||||
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"))
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,\
|
||||
|
||||
Reference in New Issue
Block a user