Compare commits
1 Commits
v0.3.6
...
833e51de69
| Author | SHA1 | Date | |
|---|---|---|---|
| 833e51de69 |
@@ -17,6 +17,7 @@ import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||
@@ -93,6 +94,14 @@ interface PublicApi {
|
||||
suspend fun postContact(@Body body: ContactRequest): ContactResponse
|
||||
|
||||
// ── Public shard widgets (§6.2) ──────────────────────────────────────
|
||||
/**
|
||||
* Which shard features this caller may reach, so the menu hides entries instead
|
||||
* of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous
|
||||
* call and a signed-in one can differ.
|
||||
*/
|
||||
@GET("api/v1/public/shard/features")
|
||||
suspend fun getShardFeatures(): ShardFeaturesDto
|
||||
|
||||
@GET("api/v1/public/shard/status")
|
||||
suspend fun getShardStatus(): ShardStatusDto
|
||||
|
||||
|
||||
@@ -83,8 +83,47 @@ data class CharProfileDto(
|
||||
val titles: TitlesDto? = null,
|
||||
val guild: GuildRefDto? = null,
|
||||
val governorOf: List<String> = emptyList(),
|
||||
/**
|
||||
* Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has
|
||||
* earned nothing anywhere — the shard omits systems the character has no entry in
|
||||
* — and empty on a shard whose plugin predates 3.0.
|
||||
*
|
||||
* Served **ungated**: a character's own standings are self-service data on
|
||||
* `/player/shard/char/:serial` and do not depend on the public `leaderboards`
|
||||
* feature being visible. Don't re-gate them app-side.
|
||||
*/
|
||||
val points: List<CharPointsDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One point system a character holds a score in (Protocol 3.0 §7.3).
|
||||
*
|
||||
* Three shapes here are counter-intuitive, and all three are what a REAL shard sends
|
||||
* (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should):
|
||||
*
|
||||
* - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case.
|
||||
* ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin
|
||||
* normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`.
|
||||
* Nothing may divide by it, and a full-width progress bar for an uncapped score
|
||||
* would imply a completion that doesn't exist.
|
||||
* - **[nameString] is usually `null`.** Most systems name themselves with a cliloc
|
||||
* rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens
|
||||
* Loyalty") is the PRIMARY display path, not a defensive fallback.
|
||||
* - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.**
|
||||
* Absent and "unranked" are different answers, so it renders only when sent.
|
||||
*/
|
||||
@Serializable
|
||||
data class CharPointsDto(
|
||||
val system: String? = null,
|
||||
val nameString: String? = null,
|
||||
val points: Long? = null,
|
||||
val maxPoints: Long? = null,
|
||||
val rank: Int? = null,
|
||||
) {
|
||||
/** The cap, or null when the system is uncapped (see [maxPoints]). */
|
||||
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CharStatsDto(
|
||||
val str: Int? = null,
|
||||
@@ -134,17 +173,45 @@ data class EquipmentDto(
|
||||
val itemId: Int? = null,
|
||||
val hue: Int? = null,
|
||||
val mods: JsonObject? = null,
|
||||
)
|
||||
/**
|
||||
* A player-given name — set for the minority of items someone has renamed, null
|
||||
* for almost everything else. The shard sends the plain `Item.Name` field; it
|
||||
* never builds a display name (that call is a packet builder, not a field read).
|
||||
*/
|
||||
val name: String? = null,
|
||||
/**
|
||||
* The item's type name, resolved from its cliloc id **by the website** against
|
||||
* its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc
|
||||
* table configured, which is fully supported — the sheet then falls back to the
|
||||
* layer, exactly as it did before the table existed.
|
||||
*/
|
||||
val clilocName: String? = null,
|
||||
) {
|
||||
/**
|
||||
* What to call this item.
|
||||
*
|
||||
* A player-given [name] outranks the resolved type name — "Bob's lucky axe" must
|
||||
* not be relabelled "hatchet" — and the server applies the same precedence, so
|
||||
* this only re-states it for an item that arrived with both.
|
||||
*/
|
||||
val label: String? get() = name ?: clilocName ?: layer
|
||||
}
|
||||
|
||||
/**
|
||||
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
|
||||
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
|
||||
* literal — numeric ones are skipped without a cliloc table (as the website does).
|
||||
* Display titles (Protocol 2.0). `selected` is the index into [reward] currently
|
||||
* shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
|
||||
*
|
||||
* [rewardResolved] is the website's **parallel array** with the numeric entries turned
|
||||
* into words against its cliloc table — same length and order as [reward], with a null
|
||||
* where an id resolved to nothing. It is absent entirely when no entry was numeric or
|
||||
* the shard has no cliloc table, so read it positionally and tolerate it being short.
|
||||
* See `displayTitles` in the character sheet.
|
||||
*/
|
||||
@Serializable
|
||||
data class TitlesDto(
|
||||
val selected: Int? = null,
|
||||
val reward: List<String> = emptyList(),
|
||||
val rewardResolved: List<String?> = emptyList(),
|
||||
val fameKarma: String? = null,
|
||||
val skill: String? = null,
|
||||
)
|
||||
|
||||
@@ -15,11 +15,36 @@ import kotlinx.serialization.json.JsonObject
|
||||
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Which shard surfaces this caller may reach (`GET /public/shard/features`), plus
|
||||
* the audience rung they resolved to.
|
||||
*
|
||||
* Every shard-derived feature is admin-configurable — it can be switched off or
|
||||
* raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11).
|
||||
* [level] is the SERVER's answer on the `anonymous → logged_in → player → staff →
|
||||
* admin` ladder and is authoritative: don't re-derive a rung from the session role,
|
||||
* since `player` means *a linked game account* and staff always satisfy it.
|
||||
*
|
||||
* The response reports only what the caller can see, so the list itself never
|
||||
* discloses a feature they're gated out of.
|
||||
*/
|
||||
@Serializable
|
||||
data class ShardFeaturesDto(
|
||||
val level: String? = null,
|
||||
val features: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
|
||||
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
|
||||
* (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a
|
||||
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
|
||||
* (e.g. `"0x1A2B"`), never numbers.
|
||||
*
|
||||
* [acct] and [webId] are **locked to the admin rung** by the visibility framework
|
||||
* (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are
|
||||
* not in-game-visible the way a character name is, so they are stripped from every
|
||||
* response below `admin` and no admin setting can loosen that. The fields stay
|
||||
* declared because an admin session does receive them; nothing below one should
|
||||
* expect a value.
|
||||
*/
|
||||
@Serializable
|
||||
data class ActorDto(
|
||||
|
||||
@@ -28,6 +28,7 @@ class ConnectionRepository @Inject constructor(
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
private val sessionManager: SessionManager,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val shardFeaturesRepository: ShardFeaturesRepository,
|
||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||
private val config: com.runicgateway.app.core.AppConfig,
|
||||
) {
|
||||
@@ -111,6 +112,10 @@ class ConnectionRepository @Inject constructor(
|
||||
// The trust token is bound to the old host — drop it so we don't replay it
|
||||
// against a different shard (it survives a plain logout, but not a host switch).
|
||||
trustTokenStore.clear()
|
||||
// Shard visibility is the OLD host's answer. Sign-out alone would not clear it:
|
||||
// a switch between two signed-out hosts changes no session, so nothing else
|
||||
// invalidates the cache and the new shard would inherit the old one's menu.
|
||||
shardFeaturesRepository.invalidate()
|
||||
prefs.clear()
|
||||
baseUrlHolder.set(null)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Which shard surfaces the current viewer may reach, from
|
||||
* `GET /public/shard/features` (PLAN.md §5, §9 M11).
|
||||
*
|
||||
* Every shard-derived feature is admin-configurable — it can be switched off, or its
|
||||
* audience raised above the caller's rung — so shard navigation can no longer be a
|
||||
* static list gated on the session role alone. [level] is the server's own answer on
|
||||
* the `anonymous → logged_in → player → staff → admin` ladder; the app does not
|
||||
* re-derive it.
|
||||
*
|
||||
* **This is presentation only.** The gate is server-side: a disabled feature `404`s
|
||||
* and an out-of-rung one `403`s whether or not the entry was rendered. That is why an
|
||||
* unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee].
|
||||
*/
|
||||
@Singleton
|
||||
class ShardFeaturesRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
) {
|
||||
private val _features = MutableStateFlow<ShardFeatures?>(null)
|
||||
|
||||
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
|
||||
val features: StateFlow<ShardFeatures?> = _features.asStateFlow()
|
||||
|
||||
// Serializes concurrent refreshes: the shell refreshes on every session change,
|
||||
// and two overlapping loads would race to publish.
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Re-resolve the visible set. Called on every session change (sign-in, sign-out,
|
||||
* a role revalidation that actually changed the user), because the answer is
|
||||
* per-viewer.
|
||||
*
|
||||
* A failed lookup clears the cache rather than keeping a stale one: falling back
|
||||
* to "show everything" is the safe direction here, since the server still gates
|
||||
* every call.
|
||||
*/
|
||||
suspend fun refresh() = mutex.withLock {
|
||||
_features.value = when (val result = safeApiCall { api.getShardFeatures() }) {
|
||||
is ApiResult.Ok -> ShardFeatures(
|
||||
level = result.data.level,
|
||||
visible = result.data.features.toSet(),
|
||||
)
|
||||
// Includes the 404 an older, pre-Protocol-3.0 website returns for this
|
||||
// route — that site has no visibility framework, so "unknown" is exactly
|
||||
// the right answer and the menu behaves as it did before M11.
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached answer. Called on a Settings → Server switch: the features
|
||||
* belong to the host that reported them, and a switch between two signed-out
|
||||
* hosts changes no session, so nothing else would invalidate them.
|
||||
*/
|
||||
fun invalidate() {
|
||||
_features.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved visibility answer for one viewer: the rung the server placed them on
|
||||
* and the shard features they may reach.
|
||||
*/
|
||||
data class ShardFeatures(
|
||||
val level: String?,
|
||||
val visible: Set<String>,
|
||||
)
|
||||
|
||||
/**
|
||||
* True when [feature] may be shown — **or when the answer isn't known yet**.
|
||||
*
|
||||
* The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`):
|
||||
* the server gates every call regardless, so the cost of guessing wrong is a link that
|
||||
* briefly `403`s, while the cost of guessing the other way is a navigation drawer that
|
||||
* flickers its entries in on every cold start.
|
||||
*/
|
||||
fun canSee(features: ShardFeatures?, feature: String): Boolean =
|
||||
features == null || feature in features.visible
|
||||
|
||||
/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */
|
||||
object ShardFeature {
|
||||
const val STATUS = "status"
|
||||
const val ACTIVITY = "activity"
|
||||
const val CHAMPS = "champs"
|
||||
const val GUILDS = "guilds"
|
||||
const val GOVERNORS = "governors"
|
||||
const val HOUSES = "houses"
|
||||
const val PRESENCE = "presence"
|
||||
|
||||
// Added by Protocol 3.0.
|
||||
const val RULESET = "ruleset"
|
||||
const val ATLAS = "atlas"
|
||||
const val LEADERBOARDS = "leaderboards"
|
||||
const val MARKET = "market"
|
||||
}
|
||||
@@ -112,6 +112,8 @@ fun RunicApp(
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||
// What this shard publishes, independently of who the caller is (§5, M11).
|
||||
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||
|
||||
// Re-validate the cached role each time the app returns to the foreground (§4.3).
|
||||
LifecycleResumeEffect(Unit) {
|
||||
@@ -132,7 +134,7 @@ fun RunicApp(
|
||||
val backStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = backStackEntry?.destination?.route
|
||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||
val entries = visibleEntries(APP_MENU, session)
|
||||
val entries = visibleEntries(APP_MENU, session, shardFeatures)
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
|
||||
@@ -34,6 +34,13 @@ enum class ErrorKind {
|
||||
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
|
||||
SHARD_OFFLINE,
|
||||
|
||||
/**
|
||||
* This shard doesn't publish the surface, or doesn't publish it to this viewer
|
||||
* (M11). Distinct from [NOT_FOUND] and [SHARD_OFFLINE]: the site is up, the shard
|
||||
* may well be up, and retrying changes nothing — an admin decides this.
|
||||
*/
|
||||
FEATURE_UNAVAILABLE,
|
||||
|
||||
/** Any other non-2xx server response. */
|
||||
SERVER,
|
||||
}
|
||||
@@ -52,3 +59,27 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
|
||||
httpStatus = status,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [toUiState] for a **shard-derived** read, where `404` carries a second meaning.
|
||||
*
|
||||
* The website's `requireFeature` gate answers `404` when a feature is switched off —
|
||||
* deliberately, so the response doesn't disclose that the surface exists — and `403`
|
||||
* when it's on but the caller is below its audience rung (`docs/link/v3.md` §3.6).
|
||||
* On these routes a `404` therefore almost never means "no such thing"; it means this
|
||||
* shard doesn't publish it. Rendering "couldn't be found" with a retry button would
|
||||
* invite the user to retry something an admin controls.
|
||||
*
|
||||
* Kept as a separate mapper rather than folded into [toUiState] because both statuses
|
||||
* mean something else off the shard surface: `404` is a genuinely missing item (a
|
||||
* deleted post, an unknown wiki slug) and `403` is an ownership or role refusal on a
|
||||
* player or admin route, which is not an admin's visibility setting.
|
||||
*/
|
||||
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
|
||||
is ApiResult.HttpError -> if (status == 403 || status == 404) {
|
||||
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
|
||||
} else {
|
||||
toUiState()
|
||||
}
|
||||
else -> toUiState()
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ fun LoadingView(modifier: Modifier = Modifier) {
|
||||
/**
|
||||
* Whole-screen error state with a friendly, kind-specific message and a Retry
|
||||
* button (§7). Copy is resolved from string resources so it stays localizable.
|
||||
*
|
||||
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
|
||||
* whether the shard publishes that surface, so retrying cannot change the answer and
|
||||
* offering it would read as a transient failure the user could wait out (M11).
|
||||
*/
|
||||
@Composable
|
||||
fun ErrorView(
|
||||
@@ -53,6 +57,7 @@ fun ErrorView(
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
if (isRetryable(kind)) {
|
||||
Button(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.padding(top = 16.dp).width(160.dp),
|
||||
@@ -60,8 +65,12 @@ fun ErrorView(
|
||||
Text(stringResource(R.string.action_retry))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
|
||||
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
|
||||
|
||||
/** Centered informational message for an empty list (§7). */
|
||||
@Composable
|
||||
fun EmptyView(message: String, modifier: Modifier = Modifier) {
|
||||
@@ -83,5 +92,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
|
||||
ErrorKind.NOT_FOUND -> R.string.error_not_found
|
||||
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
|
||||
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
|
||||
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
|
||||
ErrorKind.SERVER -> R.string.error_server
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ package com.runicgateway.app.ui.navigation
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.data.repository.ShardFeature
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.canSee
|
||||
|
||||
/**
|
||||
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||
@@ -40,6 +43,15 @@ data class MenuEntry(
|
||||
val route: String,
|
||||
@param:StringRes val labelRes: Int,
|
||||
val access: MenuAccess = MenuAccess.PUBLIC,
|
||||
/**
|
||||
* For a shard-derived surface, the visibility feature it belongs to (M11).
|
||||
*
|
||||
* Session role is not the only gate on these: an admin can switch a feature off
|
||||
* or raise its audience above the caller's rung, so the entry is filtered by
|
||||
* `GET /public/shard/features` as well as by [access]. `null` means the entry
|
||||
* isn't shard-derived and only [access] applies.
|
||||
*/
|
||||
val feature: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -51,7 +63,7 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
|
||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||
@@ -67,16 +79,28 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
)
|
||||
|
||||
/**
|
||||
* The entries the given [session] may see. Pure + side-effect-free so the access
|
||||
* gating is unit-tested without Compose.
|
||||
* The entries the given [session] may see, given the shard [features] it may reach.
|
||||
* Pure + side-effect-free so the gating is unit-tested without Compose.
|
||||
*
|
||||
* Two independent filters, and both must pass:
|
||||
*
|
||||
* - [MenuEntry.access] against the session — who the caller is.
|
||||
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
|
||||
* publishes at all (M11). `null` [features] means the answer isn't known yet and
|
||||
* every shard entry shows; see [canSee] for why that direction is deliberate.
|
||||
*/
|
||||
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
|
||||
fun visibleEntries(
|
||||
entries: List<MenuEntry>,
|
||||
session: Session,
|
||||
features: ShardFeatures? = null,
|
||||
): List<MenuEntry> =
|
||||
entries.filter { entry ->
|
||||
when (entry.access) {
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.CharPointsDto
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.CharStatsDto
|
||||
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||
@@ -71,6 +72,7 @@ private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier)
|
||||
char.stats?.let { AttributesBlock(it) }
|
||||
char.stats?.resist?.let { ResistancesBlock(it) }
|
||||
SkillsBlock(char.skills)
|
||||
PointsBlock(displayPoints(char))
|
||||
EquipmentBlock(char.equipment)
|
||||
}
|
||||
}
|
||||
@@ -215,6 +217,47 @@ private fun SkillsBlock(skills: List<SkillDto>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loyalty & points standings (Protocol 3.0 §7.3). Renders nothing at all for a
|
||||
* character that has earned nothing anywhere, which is a normal state.
|
||||
*
|
||||
* Only a system with a real cap gets a meter: an uncapped score
|
||||
* ([CharPointsDto.maxPoints] `0`, the common case on a real shard) has nothing to be
|
||||
* a fraction of, and a full-width bar would imply a completion that doesn't exist.
|
||||
*/
|
||||
@Composable
|
||||
private fun PointsBlock(points: List<CharPointsDto>) {
|
||||
if (points.isEmpty()) return
|
||||
SheetCard(R.string.player_char_points) {
|
||||
points.forEach { entry ->
|
||||
val cap = entry.cap
|
||||
val score = entry.points ?: 0L
|
||||
Column(Modifier.padding(vertical = 5.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(bottom = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
// `rank` is absent unless the shard opts in; absent and
|
||||
// "unranked" are different, so the suffix only appears when sent.
|
||||
entry.rank?.let { stringResource(R.string.player_char_points_ranked, pointsLabel(entry), it) }
|
||||
?: pointsLabel(entry),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
cap?.let { stringResource(R.string.player_char_points_of, score, it) } ?: score.toString(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
if (cap != null) StatBar((score.toDouble() / cap).coerceIn(0.0, 1.0).toFloat())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
@@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
|
||||
equipment.forEach { item ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
|
||||
Text(
|
||||
item.layer ?: stringResource(R.string.player_char_item),
|
||||
item.label ?: stringResource(R.string.player_char_item),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
val meta = listOfNotNull(
|
||||
@@ -266,23 +309,58 @@ internal fun formatSkill(value: Double): String =
|
||||
/**
|
||||
* The human-readable title chips for a [TitlesDto] (parity with the website's
|
||||
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
|
||||
* reward title — but only if it is a literal string, not a bare cliloc number
|
||||
* (the app ships no cliloc table). De-duplicated, blanks dropped.
|
||||
* reward title.
|
||||
*
|
||||
* Reward entries arrive as either a literal or a cliloc number in string form. The
|
||||
* server now resolves the numeric ones into `rewardResolved`, a **parallel** array
|
||||
* (see `docs/website/CLILOCS.md`), so the mapping below is index-preserving: an entry
|
||||
* that didn't resolve becomes null and is skipped, but must not shift the `selected`
|
||||
* index onto its neighbour. A number with no resolution is still skipped rather than
|
||||
* rendered as a raw id, which is also the whole behavior on a shard that configures
|
||||
* no cliloc table.
|
||||
*
|
||||
* Falling back to the first title that resolved (rather than showing nothing) matters
|
||||
* when the *selected* one is the unresolved entry. De-duplicated, blanks dropped.
|
||||
*/
|
||||
internal fun displayTitles(titles: TitlesDto?): List<String> {
|
||||
if (titles == null) return emptyList()
|
||||
val out = mutableListOf<String>()
|
||||
titles.fameKarma?.let { out.add(it) }
|
||||
titles.skill?.let { out.add(it) }
|
||||
val reward = titles.reward
|
||||
val sel = titles.selected ?: -1
|
||||
val candidate = when {
|
||||
sel in reward.indices -> reward[sel]
|
||||
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
|
||||
val reward = titles.reward.mapIndexed { i, raw ->
|
||||
titles.rewardResolved.getOrNull(i)
|
||||
?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) }
|
||||
}
|
||||
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
|
||||
val candidate = reward.getOrNull(titles.selected ?: -1) ?: reward.firstNotNullOfOrNull { it }
|
||||
if (!candidate.isNullOrBlank()) out.add(candidate)
|
||||
return out.filter { it.isNotBlank() }.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* A point system's display name: the shard's own [CharPointsDto.nameString] when it
|
||||
* has one, else the humanised `PointsType` key.
|
||||
*
|
||||
* The fallback is the PRIMARY path, not a defensive nicety — most systems name
|
||||
* themselves with a cliloc, so `nameString` comes back null for four of five boards
|
||||
* on a real shard (`docs/link/v3.md` §7.5). Parity with the website's
|
||||
* `humanisePoints`.
|
||||
*/
|
||||
internal fun pointsLabel(entry: CharPointsDto): String {
|
||||
entry.nameString?.takeIf { it.isNotBlank() }?.let { return it }
|
||||
val key = entry.system.orEmpty()
|
||||
return key
|
||||
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
|
||||
.replaceFirstChar { it.uppercaseChar() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The points block, best standing first, dropping systems the character has no score
|
||||
* in. Guarded for an older shard plugin that sends no `points` block at all.
|
||||
*/
|
||||
internal fun displayPoints(char: CharProfileDto): List<CharPointsDto> =
|
||||
char.points
|
||||
.filter { (it.points ?: 0L) > 0L }
|
||||
.sortedByDescending { it.points ?: 0L }
|
||||
|
||||
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
|
||||
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }
|
||||
|
||||
@@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -23,10 +25,28 @@ import javax.inject.Inject
|
||||
class SessionViewModel @Inject constructor(
|
||||
sessionManager: SessionManager,
|
||||
private val authRepository: AuthRepository,
|
||||
shardFeaturesRepository: ShardFeaturesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val session: StateFlow<Session> = sessionManager.state
|
||||
|
||||
/**
|
||||
* Which shard features this viewer may reach (M11). Held here beside [session]
|
||||
* because it answers the same question for the same consumer: what the shared
|
||||
* menu reveals. Role and feature config are independent gates — see
|
||||
* [com.runicgateway.app.ui.navigation.visibleEntries].
|
||||
*/
|
||||
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||
|
||||
init {
|
||||
// The answer is per-viewer, so it is re-resolved on every session change.
|
||||
// A StateFlow conflates equal values, so a resume revalidation that returns
|
||||
// the same user does not refetch — only a real sign-in/out/role change does.
|
||||
viewModelScope.launch {
|
||||
session.collect { shardFeaturesRepository.refresh() }
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-validate the cached role against the backend on app resume. */
|
||||
fun revalidate() {
|
||||
viewModelScope.launch { authRepository.revalidate() }
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.ChampDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor(
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor(
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor(
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor(
|
||||
board.seed(result.data)
|
||||
publish()
|
||||
}
|
||||
else -> _state.value = result.toUiState()
|
||||
else -> _state.value = result.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,12 +32,33 @@ import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.FeatureCard
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.data.repository.ShardFeature
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.canSee
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.SectionLabel
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/** Board destinations reachable from the hub. */
|
||||
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
|
||||
/**
|
||||
* Board destinations reachable from the hub, each tagged with the visibility feature
|
||||
* that governs it (M11). An admin can switch any of these off or raise its audience,
|
||||
* so the hub's board list is filtered the same way the drawer is — a tile whose
|
||||
* feature the caller can't see would only lead to a `404`/`403`.
|
||||
*/
|
||||
enum class ShardBoard(val feature: String) {
|
||||
CHAMPS(ShardFeature.CHAMPS),
|
||||
GUILDS(ShardFeature.GUILDS),
|
||||
GOVERNORS(ShardFeature.GOVERNORS),
|
||||
HOUSES(ShardFeature.HOUSES),
|
||||
}
|
||||
|
||||
/**
|
||||
* The boards this viewer may reach. Pure + side-effect-free so the gating is
|
||||
* unit-tested without Compose, exactly like `visibleEntries` for the drawer. An
|
||||
* unknown answer shows every board — the server gates regardless (see [canSee]).
|
||||
*/
|
||||
fun visibleBoards(features: ShardFeatures?): List<ShardBoard> =
|
||||
ShardBoard.entries.filter { canSee(features, it.feature) }
|
||||
|
||||
/**
|
||||
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
|
||||
@@ -54,6 +75,7 @@ fun ShardScreen(
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val feed by viewModel.feed.collectAsStateWithLifecycle()
|
||||
val connected by viewModel.connected.collectAsStateWithLifecycle()
|
||||
val features by viewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
@@ -62,6 +84,7 @@ fun ShardScreen(
|
||||
hub = s.data,
|
||||
feed = feed,
|
||||
connected = connected,
|
||||
features = features,
|
||||
onOpenBoard = onOpenBoard,
|
||||
modifier = modifier,
|
||||
)
|
||||
@@ -73,6 +96,7 @@ private fun HubContent(
|
||||
hub: ShardHub,
|
||||
feed: List<FeedLine>,
|
||||
connected: Boolean,
|
||||
features: ShardFeatures?,
|
||||
onOpenBoard: (ShardBoard) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -82,7 +106,7 @@ private fun HubContent(
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
|
||||
) {
|
||||
item { StatusCard(hub.status, hub.presence?.count) }
|
||||
item { BoardsCard(onOpenBoard) }
|
||||
item { BoardsCard(features, onOpenBoard) }
|
||||
|
||||
if (hub.online.isNotEmpty()) {
|
||||
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
|
||||
@@ -159,13 +183,16 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
|
||||
val boards = listOf(
|
||||
ShardBoard.CHAMPS to R.string.shard_nav_champs,
|
||||
ShardBoard.GUILDS to R.string.shard_nav_guilds,
|
||||
ShardBoard.GOVERNORS to R.string.shard_nav_governors,
|
||||
ShardBoard.HOUSES to R.string.shard_nav_houses,
|
||||
)
|
||||
private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) {
|
||||
val boards = visibleBoards(features).map { board ->
|
||||
board to when (board) {
|
||||
ShardBoard.CHAMPS -> R.string.shard_nav_champs
|
||||
ShardBoard.GUILDS -> R.string.shard_nav_guilds
|
||||
ShardBoard.GOVERNORS -> R.string.shard_nav_governors
|
||||
ShardBoard.HOUSES -> R.string.shard_nav_houses
|
||||
}
|
||||
}
|
||||
if (boards.isEmpty()) return
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column {
|
||||
boards.forEachIndexed { index, (board, labelRes) ->
|
||||
|
||||
@@ -10,9 +10,11 @@ import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import com.runicgateway.app.ui.toShardUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -39,11 +41,18 @@ data class ShardHub(
|
||||
@HiltViewModel
|
||||
class ShardViewModel @Inject constructor(
|
||||
private val repository: ShardRepository,
|
||||
shardFeaturesRepository: ShardFeaturesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
|
||||
|
||||
/**
|
||||
* Which boards to offer (M11). Read-only here — the app shell refreshes this on
|
||||
* every session change, and the hub only filters its tiles with it.
|
||||
*/
|
||||
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||
|
||||
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
|
||||
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
|
||||
|
||||
@@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor(
|
||||
seedFeed()
|
||||
}
|
||||
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
|
||||
is ApiResult.HttpError -> _state.value = status.toUiState()
|
||||
is ApiResult.NetworkError -> _state.value = status.toUiState()
|
||||
is ApiResult.HttpError -> _state.value = status.toShardUiState()
|
||||
is ApiResult.NetworkError -> _state.value = status.toShardUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<string name="error_not_found">This content couldn\'t be found.</string>
|
||||
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
|
||||
<string name="error_shard_offline">The shard is offline right now.</string>
|
||||
<string name="error_feature_unavailable">This shard doesn\'t publish this here.</string>
|
||||
<string name="error_server">Something went wrong on the server. Please try again.</string>
|
||||
|
||||
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
|
||||
@@ -290,6 +291,11 @@
|
||||
<string name="player_char_pois">Poison</string>
|
||||
<string name="player_char_energy">Energy</string>
|
||||
<string name="player_char_skills">Skills</string>
|
||||
<string name="player_char_points">Loyalty & Points</string>
|
||||
<!-- A point system's name followed by the character's rank on that board, e.g. "Queens Loyalty · #3". -->
|
||||
<string name="player_char_points_ranked">%1$s · #%2$d</string>
|
||||
<!-- A score against its cap. Only shown for capped systems; an uncapped score shows the number alone. -->
|
||||
<string name="player_char_points_of">%1$d / %2$d</string>
|
||||
<string name="player_char_equipment">Equipment</string>
|
||||
<string name="player_char_item">Item</string>
|
||||
<string name="player_char_item_id">id %1$d</string>
|
||||
|
||||
@@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
@@ -99,4 +100,57 @@ class PlayerShardDtoTest {
|
||||
assertTrue(dto.linked)
|
||||
assertEquals("whitlocktech", dto.account)
|
||||
}
|
||||
|
||||
// ── Protocol 3.0 additions to char.profile ───────────────────────────
|
||||
|
||||
@Test fun charProfileDecodesThePointsBlock() {
|
||||
// Shaped like a real shard's reply: an uncapped board (maxPoints 0), a
|
||||
// cliloc-named board (nameString null), and no `rank` unless opted in.
|
||||
val dto = json.decodeFromString<CharProfileDto>(
|
||||
"""{"serial":"0x24C","name":"Darrow",
|
||||
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty",
|
||||
"points":29500,"maxPoints":30000,"rank":3},
|
||||
{"system":"VoidPool","nameString":null,"points":180,"maxPoints":0}]}""",
|
||||
)
|
||||
assertEquals(2, dto.points.size)
|
||||
val queens = dto.points[0]
|
||||
assertEquals("Queen's Loyalty", queens.nameString)
|
||||
assertEquals(29500L, queens.points)
|
||||
assertEquals(30000L, queens.cap)
|
||||
assertEquals(3, queens.rank)
|
||||
|
||||
val voidPool = dto.points[1]
|
||||
assertNull("maxPoints 0 means uncapped, not a zero cap", voidPool.cap)
|
||||
assertNull("rank is absent unless the shard opts in", voidPool.rank)
|
||||
assertNull(voidPool.nameString)
|
||||
}
|
||||
|
||||
@Test fun charProfileWithoutAPointsBlockDecodesToEmpty() {
|
||||
// A shard plugin that predates Protocol 3.0 sends no `points` key at all.
|
||||
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x24C","name":"Darrow"}""")
|
||||
assertEquals(emptyList<CharPointsDto>(), dto.points)
|
||||
}
|
||||
|
||||
@Test fun equipmentDecodesTheServerResolvedClilocName() {
|
||||
val dto = json.decodeFromString<CharProfileDto>(
|
||||
"""{"serial":"0x24C",
|
||||
"equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"cliloc":1023721,
|
||||
"clilocName":"hatchet"},
|
||||
{"serial":"0x41","layer":"Shirt","name":"Bob's lucky shirt",
|
||||
"clilocName":"fancy shirt"}]}""",
|
||||
)
|
||||
assertEquals("hatchet", dto.equipment[0].label)
|
||||
assertEquals("Bob's lucky shirt", dto.equipment[1].label)
|
||||
}
|
||||
|
||||
@Test fun titlesDecodeTheParallelResolvedArrayIncludingItsNulls() {
|
||||
// rewardResolved carries a null where the cliloc table had nothing; the array
|
||||
// must stay positionally aligned with `reward`.
|
||||
val dto = json.decodeFromString<TitlesDto>(
|
||||
"""{"selected":1,"reward":["1049565","1049566"],
|
||||
"rewardResolved":[null,"Knight of Trinsic"]}""",
|
||||
)
|
||||
assertEquals(listOf("1049565", "1049566"), dto.reward)
|
||||
assertEquals(listOf(null, "Knight of Trinsic"), dto.rewardResolved)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,4 +111,30 @@ class ShardDtoTest {
|
||||
assertEquals("bob", ActorDto(acct = "bob").label)
|
||||
assertEquals("Someone", ActorDto().label)
|
||||
}
|
||||
|
||||
@Test fun actorArrivesWithoutAcctOrWebIdBelowTheAdminRung() {
|
||||
// Those two fields are locked to `admin` by the visibility framework and are
|
||||
// stripped from every response below it — the app must decode their absence,
|
||||
// not depend on them (docs/link/v3.md §3.4 rule 1).
|
||||
val dto = json.decodeFromString<ActorDto>("""{"serial":"0x24C","name":"Darrow","player":true}""")
|
||||
assertEquals("Darrow", dto.label)
|
||||
assertNull(dto.acct)
|
||||
assertNull(dto.webId)
|
||||
}
|
||||
|
||||
@Test fun shardFeaturesDecodesTheRungAndVisibleSet() {
|
||||
val dto = json.decodeFromString<ShardFeaturesDto>(
|
||||
"""{"level":"player","features":["status","champs","guilds","market"]}""",
|
||||
)
|
||||
assertEquals("player", dto.level)
|
||||
assertTrue(dto.features.contains("market"))
|
||||
assertEquals(4, dto.features.size)
|
||||
}
|
||||
|
||||
@Test fun shardFeaturesDecodesAnEmptySet() {
|
||||
// A fully-gated shard: every feature switched off for this viewer. Distinct
|
||||
// from the lookup failing, which the repository represents as null.
|
||||
val dto = json.decodeFromString<ShardFeaturesDto>("""{"level":"anonymous","features":[]}""")
|
||||
assertEquals(emptyList<String>(), dto.features)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.SettingsDto
|
||||
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.WikiCategoryDto
|
||||
@@ -56,6 +57,7 @@ class FakePublicApi : PublicApi {
|
||||
var governors: List<GovernorDto> = emptyList()
|
||||
var governorHistory: List<GovernorTermDto> = emptyList()
|
||||
var houses: List<HouseDto> = emptyList()
|
||||
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
|
||||
|
||||
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
|
||||
var lastContact: ContactRequest? = null
|
||||
@@ -84,6 +86,7 @@ class FakePublicApi : PublicApi {
|
||||
return reply(contactResponse)
|
||||
}
|
||||
|
||||
override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures)
|
||||
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
|
||||
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
|
||||
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The shard-visibility lookup (PLAN.md §9 M11). The behavior worth pinning is the
|
||||
* FAIL-OPEN direction: an unknown answer must show every entry, because the server
|
||||
* gates every call regardless and the alternative is a menu that flickers in.
|
||||
*/
|
||||
class ShardFeaturesRepositoryTest {
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private val repository = ShardFeaturesRepository(api)
|
||||
|
||||
@Test fun refreshPublishesTheVisibleSetAndTheServersRung() = runTest {
|
||||
api.shardFeatures = ShardFeaturesDto(
|
||||
level = "player",
|
||||
features = listOf("status", "champs", "market"),
|
||||
)
|
||||
|
||||
repository.refresh()
|
||||
|
||||
val features = repository.features.value
|
||||
assertEquals("player", features?.level)
|
||||
assertEquals(setOf("status", "champs", "market"), features?.visible)
|
||||
}
|
||||
|
||||
@Test fun aFeatureTheServerOmittedIsNotVisible() = runTest {
|
||||
api.shardFeatures = ShardFeaturesDto(level = "anonymous", features = listOf("status"))
|
||||
|
||||
repository.refresh()
|
||||
|
||||
assertTrue(canSee(repository.features.value, ShardFeature.STATUS))
|
||||
assertFalse(canSee(repository.features.value, ShardFeature.MARKET))
|
||||
}
|
||||
|
||||
@Test fun aFailedLookupFallsBackToUnknownRatherThanEmpty() = runTest {
|
||||
// Empty and unknown are opposite answers: empty hides everything, unknown
|
||||
// shows everything. A failure must never be read as "this shard publishes
|
||||
// nothing".
|
||||
api.error = IOException("offline")
|
||||
|
||||
repository.refresh()
|
||||
|
||||
assertNull(repository.features.value)
|
||||
assertTrue(canSee(repository.features.value, ShardFeature.MARKET))
|
||||
}
|
||||
|
||||
@Test fun aPreProtocol3WebsiteIs404AndReadsAsUnknown() = runTest {
|
||||
// The route does not exist before Protocol 3.0. That site has no visibility
|
||||
// framework at all, so "unknown" is exactly right and the menu behaves as it
|
||||
// did before M11.
|
||||
api.error = httpError(404)
|
||||
|
||||
repository.refresh()
|
||||
|
||||
assertNull(repository.features.value)
|
||||
assertTrue(canSee(repository.features.value, ShardFeature.CHAMPS))
|
||||
}
|
||||
|
||||
@Test fun aFailedRefreshClearsAPreviouslyGoodAnswer() = runTest {
|
||||
api.shardFeatures = ShardFeaturesDto(level = "admin", features = listOf("status"))
|
||||
repository.refresh()
|
||||
assertEquals(setOf("status"), repository.features.value?.visible)
|
||||
|
||||
// Signing out and failing to re-resolve must not leave the previous viewer's
|
||||
// (possibly wider) answer in place.
|
||||
api.error = httpError(500)
|
||||
repository.refresh()
|
||||
|
||||
assertNull(repository.features.value)
|
||||
}
|
||||
|
||||
@Test fun invalidateDropsTheCachedAnswer() = runTest {
|
||||
api.shardFeatures = ShardFeaturesDto(level = "staff", features = listOf("houses"))
|
||||
repository.refresh()
|
||||
assertEquals("staff", repository.features.value?.level)
|
||||
|
||||
// A Settings → Server switch: the answer belonged to the old host.
|
||||
repository.invalidate()
|
||||
|
||||
assertNull(repository.features.value)
|
||||
}
|
||||
|
||||
@Test fun canSeeTreatsUnknownAsVisibleAndEmptyAsHidden() {
|
||||
assertTrue("unknown must fail open", canSee(null, ShardFeature.RULESET))
|
||||
assertFalse(
|
||||
"an explicit empty set hides everything",
|
||||
canSee(ShardFeatures(level = "anonymous", visible = emptySet()), ShardFeature.RULESET),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.ui.components.isRetryable
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
@@ -34,6 +36,44 @@ class UiStateTest {
|
||||
assertTrue(ApiResult.HttpError(503).let { it.status == 503 })
|
||||
}
|
||||
|
||||
// ── Shard reads: 404/403 mean "this shard doesn't publish it" (M11) ──
|
||||
|
||||
@Test fun shardReadsTreat404And403AsFeatureUnavailable() {
|
||||
// requireFeature answers 404 for a disabled feature (deliberately not
|
||||
// disclosing that it exists) and 403 for a viewer below its audience rung.
|
||||
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(404))
|
||||
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(403))
|
||||
}
|
||||
|
||||
@Test fun shardReadsLeaveEveryOtherStatusAlone() {
|
||||
assertEquals(ErrorKind.SHARD_OFFLINE, shardKindOf(503))
|
||||
assertEquals(ErrorKind.RATE_LIMITED, shardKindOf(429))
|
||||
assertEquals(ErrorKind.SERVER, shardKindOf(500))
|
||||
assertEquals(
|
||||
ErrorKind.NETWORK,
|
||||
(ApiResult.NetworkError(IOException()).toShardUiState() as UiState.Error).kind,
|
||||
)
|
||||
assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toShardUiState())
|
||||
}
|
||||
|
||||
@Test fun nonShardReadsKeep404AsNotFound() {
|
||||
// The remap is scoped to shard routes on purpose: off them, a 404 is still a
|
||||
// deleted post or an unknown wiki slug.
|
||||
assertEquals(ErrorKind.NOT_FOUND, kindOf(404))
|
||||
}
|
||||
|
||||
@Test fun anUnavailableFeatureIsNotRetryable() {
|
||||
// An admin controls this, so a retry button would read as a transient failure
|
||||
// the user could wait out.
|
||||
assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE))
|
||||
for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) {
|
||||
assertTrue("$kind should offer a retry", isRetryable(kind))
|
||||
}
|
||||
}
|
||||
|
||||
private fun kindOf(status: Int): ErrorKind =
|
||||
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
|
||||
|
||||
private fun shardKindOf(status: Int): ErrorKind =
|
||||
(ApiResult.HttpError(status).toShardUiState() as UiState.Error).kind
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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 com.runicgateway.app.ui.shard.ShardBoard
|
||||
import com.runicgateway.app.ui.shard.visibleBoards
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The second gate on a shard entry (PLAN.md §5, §9 M11): the shard's admin-configured
|
||||
* visibility, independent of the session role. A signed-in admin still doesn't see a
|
||||
* board the shard doesn't publish, and an anonymous visitor still doesn't see a
|
||||
* signed-in entry however wide the feature config is.
|
||||
*/
|
||||
class MenuFeatureGatingTest {
|
||||
|
||||
private fun signedIn(role: Role) =
|
||||
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
|
||||
|
||||
private fun features(vararg visible: String) =
|
||||
ShardFeatures(level = "anonymous", visible = visible.toSet())
|
||||
|
||||
private val shardEntry = MenuEntry("shard", 0, MenuAccess.PUBLIC, feature = ShardFeature.STATUS)
|
||||
private val plainEntry = MenuEntry("news", 0, MenuAccess.PUBLIC)
|
||||
|
||||
@Test fun aShardEntryHidesWhenItsFeatureIsNotVisible() {
|
||||
val entries = listOf(plainEntry, shardEntry)
|
||||
|
||||
val visible = visibleEntries(entries, Session.SignedOut, features("champs")).map { it.route }
|
||||
|
||||
assertEquals(listOf("news"), visible)
|
||||
}
|
||||
|
||||
@Test fun aShardEntryShowsWhenItsFeatureIsVisible() {
|
||||
val entries = listOf(plainEntry, shardEntry)
|
||||
|
||||
val visible = visibleEntries(entries, Session.SignedOut, features("status")).map { it.route }
|
||||
|
||||
assertEquals(listOf("news", "shard"), visible)
|
||||
}
|
||||
|
||||
@Test fun unknownFeaturesShowEverythingTheRoleAllows() {
|
||||
// Fail open while the lookup is in flight or has failed — the server gates
|
||||
// regardless, so a link that briefly 403s beats a nav that flickers in.
|
||||
val entries = listOf(plainEntry, shardEntry)
|
||||
|
||||
val visible = visibleEntries(entries, Session.SignedOut, features = null).map { it.route }
|
||||
|
||||
assertEquals(listOf("news", "shard"), visible)
|
||||
}
|
||||
|
||||
@Test fun theTwoGatesAreIndependent() {
|
||||
val staffShardEntry = MenuEntry("s", 0, MenuAccess.STAFF, feature = ShardFeature.HOUSES)
|
||||
val entries = listOf(staffShardEntry)
|
||||
|
||||
// Right role, feature switched off → hidden.
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN), features("champs")).isEmpty())
|
||||
// Feature on, wrong role → hidden.
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER), features("houses")).isEmpty())
|
||||
// Both → shown.
|
||||
assertFalse(visibleEntries(entries, signedIn(Role.ADMIN), features("houses")).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun anAdminDoesNotBypassAFeatureGate() {
|
||||
// The rung the server placed the caller on is what /features already accounts
|
||||
// for. A staff role is not a licence to render a link to a disabled feature —
|
||||
// a disabled feature 404s for everyone.
|
||||
val entries = listOf(shardEntry)
|
||||
|
||||
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN), features()).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun everyShardMenuEntryDeclaresAFeature() {
|
||||
// A shard-derived entry with no feature name silently skips the gate. The app
|
||||
// menu's only such entry today is the Shard hub; this fails if one is added
|
||||
// without one.
|
||||
val shardRoutes = APP_MENU.filter { it.route == Routes.SHARD }
|
||||
assertTrue(shardRoutes.isNotEmpty())
|
||||
assertTrue(shardRoutes.all { it.feature != null })
|
||||
}
|
||||
|
||||
// ── The hub's board tiles use the same gate ──────────────────────────
|
||||
@Test fun hubBoardsAreFilteredByFeature() {
|
||||
val visible = visibleBoards(features("champs", "houses"))
|
||||
|
||||
assertEquals(listOf(ShardBoard.CHAMPS, ShardBoard.HOUSES), visible)
|
||||
}
|
||||
|
||||
@Test fun hubBoardsShowAllWhenTheAnswerIsUnknown() {
|
||||
assertEquals(ShardBoard.entries.toList(), visibleBoards(null))
|
||||
}
|
||||
|
||||
@Test fun eachBoardMapsToItsOwnFeature() {
|
||||
assertEquals(ShardFeature.CHAMPS, ShardBoard.CHAMPS.feature)
|
||||
assertEquals(ShardFeature.GUILDS, ShardBoard.GUILDS.feature)
|
||||
assertEquals(ShardFeature.GOVERNORS, ShardBoard.GOVERNORS.feature)
|
||||
assertEquals(ShardFeature.HOUSES, ShardBoard.HOUSES.feature)
|
||||
}
|
||||
}
|
||||
@@ -3,14 +3,18 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.player
|
||||
|
||||
import com.runicgateway.app.data.api.dto.CharPointsDto
|
||||
import com.runicgateway.app.data.api.dto.CharProfileDto
|
||||
import com.runicgateway.app.data.api.dto.EquipmentDto
|
||||
import com.runicgateway.app.data.api.dto.TitlesDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the
|
||||
* website's `CharacterSheet.jsx#displayTitles`: fame/karma + skill + a *literal*
|
||||
* selected reward title, dropping bare cliloc numbers the app can't resolve.
|
||||
* website's `CharacterSheet.jsx`: title selection over the server's cliloc-resolved
|
||||
* parallel array, item naming precedence, and the Protocol 3.0 points block.
|
||||
*/
|
||||
class CharacterSheetHelpersTest {
|
||||
|
||||
@@ -51,4 +55,100 @@ class CharacterSheetHelpersTest {
|
||||
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
|
||||
assertEquals(listOf("The Great"), displayTitles(titles))
|
||||
}
|
||||
|
||||
// ── Cliloc-resolved titles (Protocol 3.0 §8.6) ───────────────────────
|
||||
|
||||
@Test fun displayTitlesPrefersTheServerResolvedRewardName() {
|
||||
// The website resolves the numeric entries against its own cliloc table and
|
||||
// sends a parallel array; the raw number is no longer the only thing we have.
|
||||
val titles = TitlesDto(
|
||||
selected = 0,
|
||||
reward = listOf("1049565"),
|
||||
rewardResolved = listOf("Knight of Trinsic"),
|
||||
)
|
||||
assertEquals(listOf("Knight of Trinsic"), displayTitles(titles))
|
||||
}
|
||||
|
||||
@Test fun displayTitlesKeepsSelectedAlignedWhenAnEntryDoesNotResolve() {
|
||||
// rewardResolved is POSITIONAL. An entry the table had nothing for is null and
|
||||
// must be skipped WITHOUT shifting `selected` onto its neighbour — otherwise
|
||||
// the sheet confidently shows the wrong title.
|
||||
val titles = TitlesDto(
|
||||
selected = 1,
|
||||
reward = listOf("1049565", "1049566"),
|
||||
rewardResolved = listOf(null, "Knight of Trinsic"),
|
||||
)
|
||||
assertEquals(listOf("Knight of Trinsic"), displayTitles(titles))
|
||||
}
|
||||
|
||||
@Test fun displayTitlesFallsBackWhenTheSelectedTitleDidNotResolve() {
|
||||
val titles = TitlesDto(
|
||||
selected = 0,
|
||||
reward = listOf("1049565", "1049566"),
|
||||
rewardResolved = listOf(null, "Bane of Dragons"),
|
||||
)
|
||||
assertEquals(listOf("Bane of Dragons"), displayTitles(titles))
|
||||
}
|
||||
|
||||
@Test fun displayTitlesStillSkipsNumbersWhenNothingResolved() {
|
||||
// A shard that configures no cliloc table sends no rewardResolved at all —
|
||||
// the pre-3.0 behavior, unchanged.
|
||||
val titles = TitlesDto(selected = 0, reward = listOf("1049565"), rewardResolved = emptyList())
|
||||
assertEquals(emptyList<String>(), displayTitles(titles))
|
||||
}
|
||||
|
||||
// ── Equipment names ──────────────────────────────────────────────────
|
||||
|
||||
@Test fun itemLabelPrefersAPlayerGivenNameOverTheResolvedTypeName() {
|
||||
// "Bob's lucky axe" must not be relabelled "hatchet".
|
||||
val item = EquipmentDto(layer = "OneHanded", name = "Bob's lucky axe", clilocName = "hatchet")
|
||||
assertEquals("Bob's lucky axe", item.label)
|
||||
}
|
||||
|
||||
@Test fun itemLabelFallsBackThroughClilocNameThenLayer() {
|
||||
assertEquals("hatchet", EquipmentDto(layer = "OneHanded", clilocName = "hatchet").label)
|
||||
assertEquals("OneHanded", EquipmentDto(layer = "OneHanded").label)
|
||||
assertNull(EquipmentDto().label)
|
||||
}
|
||||
|
||||
// ── Loyalty & points (Protocol 3.0 §7.3) ─────────────────────────────
|
||||
|
||||
@Test fun pointsLabelUsesTheHumanisedKeyWhenTheNameIsACliloc() {
|
||||
// The PRIMARY path on a real shard: most systems name themselves with a
|
||||
// cliloc, so nameString comes back null.
|
||||
assertEquals("Queens Loyalty", pointsLabel(CharPointsDto(system = "QueensLoyalty")))
|
||||
assertEquals("Clean Up Britannia", pointsLabel(CharPointsDto(system = "CleanUpBritannia")))
|
||||
assertEquals("Void Pool", pointsLabel(CharPointsDto(system = "VoidPool")))
|
||||
}
|
||||
|
||||
@Test fun pointsLabelPrefersTheShardsOwnNameWhenItHasOne() {
|
||||
val entry = CharPointsDto(system = "QueensLoyalty", nameString = "Queen's Loyalty")
|
||||
assertEquals("Queen's Loyalty", pointsLabel(entry))
|
||||
}
|
||||
|
||||
@Test fun anUncappedSystemReportsNoCap() {
|
||||
// maxPoints 0 means UNCAPPED and is the common case — three of five live
|
||||
// boards on a real shard. Nothing may divide by it.
|
||||
assertNull(CharPointsDto(points = 900, maxPoints = 0).cap)
|
||||
assertNull(CharPointsDto(points = 900, maxPoints = null).cap)
|
||||
assertEquals(30000L, CharPointsDto(points = 900, maxPoints = 30000).cap)
|
||||
}
|
||||
|
||||
@Test fun displayPointsDropsZeroesAndSortsByStandingDescending() {
|
||||
val char = CharProfileDto(
|
||||
points = listOf(
|
||||
CharPointsDto(system = "A", points = 10),
|
||||
CharPointsDto(system = "Zero", points = 0),
|
||||
CharPointsDto(system = "B", points = 500),
|
||||
CharPointsDto(system = "Null", points = null),
|
||||
),
|
||||
)
|
||||
assertEquals(listOf("B", "A"), displayPoints(char).map { it.system })
|
||||
}
|
||||
|
||||
@Test fun displayPointsIsEmptyForAProfileWithNoPointsBlock() {
|
||||
// A pre-3.0 shard plugin sends none, and a new character has earned nothing —
|
||||
// both render as nothing at all rather than an empty card.
|
||||
assertEquals(emptyList<CharPointsDto>(), displayPoints(CharProfileDto()))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.runicgateway.app.data.api.dto.PresenceDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.api.fake.FakeShardStream
|
||||
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
@@ -39,6 +40,10 @@ class ShardBoardViewModelTest {
|
||||
|
||||
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
|
||||
|
||||
// The hub reads the feature set only to filter its board tiles; these tests
|
||||
// exercise loading, so the answer stays at its "unknown" default (show all).
|
||||
private fun features() = ShardFeaturesRepository(api)
|
||||
|
||||
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
|
||||
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
|
||||
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
|
||||
@@ -108,7 +113,7 @@ class ShardBoardViewModelTest {
|
||||
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
|
||||
),
|
||||
)
|
||||
val vm = ShardViewModel(repo(stream))
|
||||
val vm = ShardViewModel(repo(stream), features())
|
||||
val hub = (vm.state.value as UiState.Success).data
|
||||
assertTrue(hub.status.isOnline)
|
||||
// presence.online frame patched the count in place.
|
||||
@@ -118,6 +123,6 @@ class ShardBoardViewModelTest {
|
||||
|
||||
@Test fun shardHubStatusErrorIsUiError() {
|
||||
api.error = httpError(503)
|
||||
assertTrue(ShardViewModel(repo()).state.value is UiState.Error)
|
||||
assertTrue(ShardViewModel(repo(), features()).state.value is UiState.Error)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user