10 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:49:12 -05:00
fbe8b0bab6 Merge pull request 'feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)' (#38) from feat/m12-phase-5-public-nav into edge
Reviewed-on: #38
2026-08-08 12:12:44 +00:00
94a5c26d6c feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)
The drawer has been the app's coded `APP_MENU` in coded order since M1. Phase 5
lets an admin's `nav_public` row relabel, reorder and hide its public rows, which
is the first time anything in the app's navigation comes from the shard.

The public nav is keyed by **website** paths, so this needs a translation table,
and it is the one new piece of cross-repo coupling the milestone introduces. It
lives in a single file with the website's own `NAV` array quoted beside it —
`NavPaths.kt` — so the coupling is visible and reviewable in one place instead of
spread across the drawer's call sites. The `feature` values are deliberately not
mirrored: `APP_MENU` stays 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.

Nine of the sixteen website rows have a drawer row. The other seven map to a
screen the app reaches another way — 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 — and an override for one of them is
**ignored**, which is §6.1's rule that a nav override may never introduce
navigation. The hub is a design decision, not an accident to correct. 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.

The merge is a port of the website's `applyNavOverrides`, narrowed to what a
drawer can express — `label`, `order`, `hidden`, and nothing else. It runs
**before** `visibleEntries`, so the two gates from M10/M11 still decide what this
caller may see and remain the actual boundary: an override that relabels the
Market row, moves it to the front and says `hidden: false` still shows nothing to
a caller whose shard does not publish the market. Hiding is subtractive, never
additive.

One thing the design did not settle and the sort turns on: an untouched row's
implicit key has to be its index in the **website's** nav, not the app's. A
stored `order` is a position in that list, so a key taken from the app's shorter
list would put explicit and implicit keys on two incomparable number lines and
scramble a partially-overridden nav. Both tie-breaks are the web's — an explicit
order beats a coincidental index, and two explicit orders keep code order.

`Routes.news(category)` and an optional NavHost argument ship here as the table's
route builder; phase 6 is their first caller. Navigating to plain `Routes.NEWS`
matches the new pattern with no argument and opens the default tab, so the drawer
and the push deep-link are unaffected — but `destination.route` is now a pattern
with a query, so the top-level and selected-row checks compare on the part before
it.

Two questions went to the org lead before any code. The three news-category paths
get a mapped route but no drawer row of their own, on the same rule as the hub
four. And an admin **may** hide Home, mirroring the website, where `/` is
hideable too: Home stays the NavHost's start destination and stays reachable by
back-press, and the app does not invent a policy the site doesn't have.

442 unit tests green (410 + 32), `lintDebug` and `assembleDebug` clean. The
strongest of them is AC-1's: with no stored row the merge returns `APP_MENU`
itself — identity, not equality — so an instance whose admin never touched the
nav provably gets the drawer the app shipped with.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:07:10 -05:00
b95fc45548 Merge pull request 'feat(brand): draw the shard's logo and hero (M12 phase 4)' (#37) from feat/m12-phase-4-brand-assets into edge
Reviewed-on: #37
2026-08-08 11:52:03 +00:00
3edd45d5f4 feat(brand): draw the shard's logo and hero (M12 phase 4)
`brand.logo` and `brand.hero` have ridden in `BrandDto` since M1 and neither
has ever been drawn — the app spells the instance out in text everywhere the
website shows a mark. Phase 4 renders them on the three surfaces §5.6 names:
the logo above the name in the drawer header, the logo in place of the
uppercased title in the top bar, and the hero as a band above Home's title
block.

Nothing new is fetched. `LocalAssetResolver` already turns a site-relative
`/uploads/…` path into an absolute URL and Coil is already a dependency, so
this phase is entirely presentation.

The rule that governs the file is §5.6's: an empty slot renders nothing — not
a placeholder, not a reserved gap. Every size modifier hangs off the image
itself, so when the image is not composed neither is its padding, and a caller
that wants space below a hero passes `Modifier.padding` instead of a sibling
`Spacer`. A failed load is an empty slot: no broken-image icon, no retry.

The top bar is the one place where "empty" is not "nothing". The logo replaces
the title there, so a 404 would strand the app in an unnamed shell until the
next resume refresh; it falls back to the text, which is what empty already
showed. There is no fallback while the load is in flight — drawing the text
first would flash text to logo on every navigation for one frame.

The hero is a fixed 180dp band, cropped, rather than the intrinsic aspect the
app's other images draw at. The website's hero is a CSS background driven by
`hero_layout`, which the app does not port, and the website's default hero is a
square emblem — at the intrinsic aspect an uploaded square would be a ~360dp
block that pushes the status card off the first screenful. It clips to
`shapes.medium`, so it follows `--radius-card` like every other surface.

The logo carries a content description only in the top bar, where it stands
alone; beside the name in text it is decorative, the same call the website's
`alt=''` makes.

410 unit tests green (401 + 9), `lintDebug` and `assembleDebug` clean. The
drawing itself is out of reach for JVM tests — the app carries no Robolectric,
so a composable body cannot run — but the decision of *whether* to draw is
pure, and `brandAssetUrl` is pulled out so it can be pinned.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 06:13:16 -05:00
19 changed files with 2129 additions and 43 deletions

View File

@@ -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 },

View File

@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -48,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))
})

View File

@@ -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() }

View File

@@ -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,

View File

@@ -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))
}

View File

@@ -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
}
}
}

View 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
}
}

View File

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

View File

@@ -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"). */

View File

@@ -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)

View File

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

View File

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

View File

@@ -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) ─────────────────────────────────

View File

@@ -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") { " " })
}
}

View File

@@ -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)
}
}

View File

@@ -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"))
}
}

View File

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

View File

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

View File

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