feat(demo): explain the public demo's refusals and show its strip on Home
All checks were successful
PR Checks / android-build (pull_request) Successful in 20m2s

Step 4 of docs website/DEMO_MODE.md §10 (website#222).

- DemoLockInterceptor on the main client peeks every 403 body. On
  code DEMO_LOCKED it reports the server's sentence to DemoNotices, and the
  shell shows it as one snackbar, like the web's toast (DM19). A newer refusal
  replaces the one showing. Screens added later get it without doing anything.
- ApiResult.HttpError carries the body's code (read only on a 403, peeked so
  the body is not consumed); isDemoLocked(). ErrorKind.DEMO_LOCKED is mapped
  ahead of the status in toUiState and toShardUiState, and is not retryable.
- Screens whose 403 copy blames the role now say the demo sentence instead:
  the four admin view models, Characters' account creation, and the contact
  form's line (the contact form is locked on the demo).
- Home draws the strip from GET /public/settings' demo block: the wipe time in
  UTC, the published usernames, and the site's sign-in page for the passwords.
  The app does not list the passwords (DM20). A real shard sends no block, so
  it draws nothing.

Checked on an emulator against website main (69998ec) with DEMO_MODE=true and
seed:demo: the strip on Home; demo-player's username change refused with the
snackbar under the screen's own line; demo-admin's site-mode switch refused
with the demo sentence in place of "You don't have permission".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-26 06:29:16 -05:00
parent 478206e0bd
commit aca358d72b
33 changed files with 780 additions and 39 deletions

View File

@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import com.runicgateway.app.core.result.DEMO_LOCKED_STATUS
import com.runicgateway.app.core.result.demoRefusalOf
import okhttp3.Interceptor
import okhttp3.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Spots the public demo's refusal on any response of the main client and reports it
* to [DemoNotices] (`docs/website/DEMO_MODE.md` §3.1, DM19). Every API rides this
* client, so a screen added later is covered without doing anything.
*
* It only reads: the body is peeked, not consumed, and the response goes on to its
* caller unchanged. Only a `403` is looked at, so an ordinary answer is never
* buffered.
*/
@Singleton
class DemoLockInterceptor @Inject constructor(
private val notices: DemoNotices,
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.code != DEMO_LOCKED_STATUS) return response
val body = runCatching { response.peekBody(MAX_PEEK_BYTES).string() }.getOrNull()
demoRefusalOf(body)?.let(notices::report)
return response
}
private companion object {
/** The refusal is one short JSON object; anything longer is not one. */
const val MAX_PEEK_BYTES = 8L * 1024
}
}

View File

@@ -0,0 +1,35 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import com.runicgateway.app.core.result.DemoRefusal
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
import javax.inject.Singleton
/**
* The app's one channel for the public demo's refusals (`docs/website/DEMO_MODE.md`
* §3.1, DM19) — the counterpart of the web client's `demoNotice.js`.
* [DemoLockInterceptor] reports every `DEMO_LOCKED` answer here and the shell shows
* it as one snackbar, wherever the action was, so no screen has to learn the code.
* The screen still gets its error as usual.
*
* A refusal nobody is collecting (the app in the background) is dropped rather than
* queued: a snackbar about a tap from minutes ago would explain nothing.
*/
@Singleton
class DemoNotices @Inject constructor() {
private val _refusals = MutableSharedFlow<DemoRefusal>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val refusals: SharedFlow<DemoRefusal> = _refusals.asSharedFlow()
fun report(refusal: DemoRefusal) {
_refusals.tryEmit(refusal)
}
}

View File

@@ -14,12 +14,18 @@ import java.io.IOException
* an expected failure, it returns a typed variant the screen can render.
*
* - [Ok] — a 2xx response with a decoded body.
* - [HttpError] — the server answered with a non-2xx status.
* - [HttpError] — the server answered with a non-2xx status. [HttpError.code] is
* the error body's `code` field when it carried one (today only
* the demo's `DEMO_LOCKED` is read, see [isDemoLocked]).
* - [NetworkError] — the request never got an answer (offline, DNS, TLS, timeout).
*/
sealed interface ApiResult<out T> {
data class Ok<T>(val data: T) : ApiResult<T>
data class HttpError(val status: Int, val message: String? = null) : ApiResult<Nothing>
data class HttpError(
val status: Int,
val message: String? = null,
val code: String? = null,
) : ApiResult<Nothing>
data class NetworkError(val cause: Throwable) : ApiResult<Nothing>
}
@@ -54,12 +60,31 @@ suspend fun <T> safeApiCall(block: suspend () -> T): ApiResult<T> = try {
} catch (e: CancellationException) {
throw e
} catch (e: HttpException) {
ApiResult.HttpError(e.code(), e.message())
ApiResult.HttpError(e.code(), e.message(), errorCodeOf(e))
} catch (e: IOException) {
ApiResult.NetworkError(e)
} catch (e: SerializationException) {
ApiResult.HttpError(MALFORMED_RESPONSE_STATUS, e.message)
}
/**
* The error body's `code`, read only for a status that can carry one the app acts on,
* so an ordinary 404 or 500 body is never looked at. The body is peeked, not
* consumed, so whoever else holds the exception can still read it.
*/
private fun errorCodeOf(e: HttpException): String? {
if (e.code() != DEMO_LOCKED_STATUS) return null
val body = runCatching {
e.response()?.errorBody()?.source()?.let { source ->
source.request(MAX_ERROR_PEEK_BYTES)
source.peek().readUtf8(minOf(source.buffer.size, MAX_ERROR_PEEK_BYTES))
}
}.getOrNull()
return errorCodeOf(body)
}
/** The refusal is one short JSON object; anything longer is not one. */
private const val MAX_ERROR_PEEK_BYTES = 8L * 1024
/** Synthetic status for a 2xx body the app couldn't decode — an invalid upstream response. */
private const val MALFORMED_RESPONSE_STATUS = 502

View File

@@ -0,0 +1,54 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* The public demo's refusal (`docs/website/DEMO_MODE.md` §3.1). With `DEMO_MODE` on,
* the server answers a locked write
* `403 { "message": "This is turned off on the demo site.", "code": "DEMO_LOCKED" }`.
* A module may word its own sentence (a refused console command names the command),
* so the message is carried as sent.
*
* The lock is the server's (DM10). Nothing in the app decides what is allowed; it
* only explains what was refused.
*/
const val DEMO_LOCKED_CODE = "DEMO_LOCKED"
/** Status the server answers a demo refusal with. */
const val DEMO_LOCKED_STATUS = 403
/** A refusal read off a response body. [message] is null when the body carried none. */
data class DemoRefusal(val message: String?)
private val lenient = Json { ignoreUnknownKeys = true }
/**
* The `code` field of a JSON error body, or null for any body that is not a JSON
* object carrying a string `code`. Never throws: an error body is whatever the
* server or a proxy in front of it sent.
*/
fun errorCodeOf(body: String?): String? = errorObject(body)?.stringField("code")
/** The refusal in this error body, or null when it is not one. */
fun demoRefusalOf(body: String?): DemoRefusal? {
val obj = errorObject(body) ?: return null
if (obj.stringField("code") != DEMO_LOCKED_CODE) return null
return DemoRefusal(obj.stringField("message")?.takeIf { it.isNotBlank() })
}
/** True when this result is the demo's refusal. */
fun ApiResult<*>.isDemoLocked(): Boolean =
this is ApiResult.HttpError && code == DEMO_LOCKED_CODE
private fun errorObject(body: String?): JsonObject? {
if (body.isNullOrBlank()) return null
return runCatching { lenient.parseToJsonElement(body) as? JsonObject }.getOrNull()
}
private fun JsonObject.stringField(name: String): String? =
(get(name) as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -146,4 +146,14 @@ data class SettingsDto(
* nav.
*/
@SerialName("nav_public") val navPublic: String? = null,
/**
* The public demo's banner data, `{ resetsAt, logins: [{ username, role, … }] }`
* (`docs/website/DEMO_MODE.md` §6). Sent only while the server runs with
* `DEMO_MODE` on, so its absence is how a real shard draws no banner.
*
* Raw for the same reason as [theme]: the banner is a nicety, and a value of an
* unexpected shape must not fail the decode of the whole payload. Read by
* `DemoInfo.from`.
*/
val demo: JsonElement? = null,
)

View File

@@ -8,6 +8,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.DemoLockInterceptor
import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
@@ -58,7 +59,8 @@ object NetworkModule {
/**
* The main client every API and the SSE stream ride on. Order: identify (UA),
* retarget onto the configured shard host, then attach the bearer; the
* retarget onto the configured shard host, attach the bearer, then spot the
* public demo's refusal on the way back (DEMO_MODE.md §3.1); the
* [TokenAuthenticator] handles silent refresh-on-401 (§4.1). Logging sits last
* so it observes the final, authed request.
*/
@@ -68,12 +70,14 @@ object NetworkModule {
hostSelectionInterceptor: HostSelectionInterceptor,
userAgentInterceptor: UserAgentInterceptor,
authInterceptor: AuthInterceptor,
demoLockInterceptor: DemoLockInterceptor,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient {
val builder = OkHttpClient.Builder()
.addInterceptor(userAgentInterceptor)
.addInterceptor(hostSelectionInterceptor)
.addInterceptor(authInterceptor)
.addInterceptor(demoLockInterceptor)
.authenticator(tokenAuthenticator)
if (BuildConfig.DEBUG) {

View File

@@ -26,6 +26,8 @@ import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
@@ -33,6 +35,7 @@ import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -65,6 +68,7 @@ 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.demo.DemoNoticeViewModel
import com.runicgateway.app.ui.events.EventScreen
import com.runicgateway.app.ui.events.EventSeriesScreen
import com.runicgateway.app.ui.events.EventsScreen
@@ -110,6 +114,7 @@ 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.flow.collectLatest
import kotlinx.coroutines.launch
/** Destinations that show the drawer (hamburger); others show a back arrow. */
@@ -151,6 +156,7 @@ fun RunicApp(
sessionViewModel: SessionViewModel = hiltViewModel(),
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
demoNoticeViewModel: DemoNoticeViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
@@ -192,6 +198,18 @@ fun RunicApp(
// the previous account's count on the drawer.
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
// The public demo's refusals (DEMO_MODE.md §3.1, DM19): one snackbar for every
// screen, the app's counterpart of the web's toast. The screen keeps its own
// failure line; this says why, in the server's words. A newer refusal replaces
// the one showing rather than queueing behind it.
val snackbarHostState = remember { SnackbarHostState() }
val demoLockedFallback = stringResource(R.string.error_demo_locked)
LaunchedEffect(Unit) {
demoNoticeViewModel.refusals.collectLatest { refusal ->
snackbarHostState.showSnackbar(refusal.message ?: demoLockedFallback)
}
}
// A tapped push notification deep-links to its stream's screen (§11, item 7).
LaunchedEffect(deepLinkStream, deepLinkRef) {
val stream = deepLinkStream ?: return@LaunchedEffect
@@ -347,6 +365,7 @@ fun RunicApp(
) {
Scaffold(
modifier = modifier,
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
colors = TopAppBarDefaults.topAppBarColors(

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
/**
* The load state a screen renders. View models expose a `StateFlow<UiState<T>>`;
@@ -41,6 +42,14 @@ enum class ErrorKind {
*/
FEATURE_UNAVAILABLE,
/**
* The public demo refused this (`403 DEMO_LOCKED`, `docs/website/DEMO_MODE.md`
* §3.1). Checked ahead of the status, because a `403` otherwise reads as "you
* may not", which blames the role of somebody signed in as an admin. Like
* [FEATURE_UNAVAILABLE], retrying cannot change the answer.
*/
DEMO_LOCKED,
/** Any other non-2xx server response. */
SERVER,
}
@@ -50,10 +59,11 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
is ApiResult.Ok -> UiState.Success(data)
is ApiResult.NetworkError -> UiState.Error(ErrorKind.NETWORK)
is ApiResult.HttpError -> UiState.Error(
kind = when (status) {
404 -> ErrorKind.NOT_FOUND
429 -> ErrorKind.RATE_LIMITED
503 -> ErrorKind.SHARD_OFFLINE
kind = when {
isDemoLocked() -> ErrorKind.DEMO_LOCKED
status == 404 -> ErrorKind.NOT_FOUND
status == 429 -> ErrorKind.RATE_LIMITED
status == 503 -> ErrorKind.SHARD_OFFLINE
else -> ErrorKind.SERVER
},
httpStatus = status,
@@ -76,7 +86,7 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
* 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) {
is ApiResult.HttpError -> if ((status == 403 || status == 404) && !isDemoLocked()) {
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
} else {
toUiState()

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
@@ -133,8 +134,14 @@ class AdminContentViewModel @Inject constructor(
/** Map an [ApiResult] to a [Feedback], with role/permission-aware failure copy. */
private fun ApiResult<*>.asFeedback(@StringRes okRes: Int): Feedback = when (this) {
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError ->
Feedback(false, if (status == 403) R.string.admin_forbidden else R.string.admin_action_failed)
is ApiResult.HttpError -> Feedback(
false,
when {
isDemoLocked() -> R.string.error_demo_locked
status == 403 -> R.string.admin_forbidden
else -> R.string.admin_action_failed
},
)
is ApiResult.NetworkError -> Feedback(false, R.string.error_network)
}
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
@@ -79,7 +80,11 @@ class AdminDashboardViewModel @Inject constructor(
switching = false,
feedback = Feedback(
false,
if (result.status == 403) R.string.admin_forbidden else R.string.admin_action_failed,
when {
result.isDemoLocked() -> R.string.error_demo_locked
result.status == 403 -> R.string.admin_forbidden
else -> R.string.admin_action_failed
},
),
)
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.repository.AdminRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -75,9 +76,10 @@ class AdminModerationViewModel @Inject constructor(
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
503 -> R.string.admin_mod_shard_offline
when {
r.isDemoLocked() -> R.string.error_demo_locked
r.status == 403 -> R.string.admin_forbidden
r.status == 503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
@@ -71,10 +72,11 @@ class AdminSupportViewModel @Inject constructor(
is ApiResult.Ok -> Feedback(true, okRes)
is ApiResult.HttpError -> Feedback(
false,
when (r.status) {
403 -> R.string.admin_forbidden
404 -> R.string.admin_support_unknown_page
503 -> R.string.admin_mod_shard_offline
when {
r.isDemoLocked() -> R.string.error_demo_locked
r.status == 403 -> R.string.admin_forbidden
r.status == 404 -> R.string.admin_support_unknown_page
r.status == 503 -> R.string.admin_mod_shard_offline
else -> R.string.admin_action_failed
},
)

View File

@@ -39,7 +39,9 @@ fun LoadingView(modifier: Modifier = Modifier) {
*
* [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).
* offering it would read as a transient failure the user could wait out (M11). The
* public demo's refusal ([ErrorKind.DEMO_LOCKED]) is the same: it is fixed until the
* site stops being a demo.
*/
@Composable
fun ErrorView(
@@ -69,7 +71,8 @@ fun ErrorView(
}
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
fun isRetryable(kind: ErrorKind): Boolean =
kind != ErrorKind.FEATURE_UNAVAILABLE && kind != ErrorKind.DEMO_LOCKED
/** Centered informational message for an empty list (§7). */
@Composable
@@ -93,5 +96,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
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.DEMO_LOCKED -> R.string.error_demo_locked
ErrorKind.SERVER -> R.string.error_server
}

View File

@@ -117,5 +117,6 @@ private fun ResultMessage(result: Result) {
private fun errorText(kind: ErrorKind): String = when (kind) {
ErrorKind.NETWORK -> stringResource(R.string.error_network)
ErrorKind.RATE_LIMITED -> stringResource(R.string.error_rate_limited)
ErrorKind.DEMO_LOCKED -> stringResource(R.string.error_demo_locked)
else -> stringResource(R.string.contact_error)
}

View File

@@ -0,0 +1,65 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.demo
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import com.runicgateway.app.R
/**
* The public demo's strip on Home (`docs/website/DEMO_MODE.md` §6), worded like the
* web's: "**Demo site** — anything you change is wiped at 14:00 UTC. Sign-ins:
* demo-admin / demo-player — passwords at demo.runicgateway.com/account/login."
*
* Drawn only from a [DemoInfo] the server sent, so a real shard never shows it and
* the app needs no schedule of its own. [signInPage] is null when the site address
* is unknown, and the logins sentence then names the sign-in page without it.
*/
@Composable
fun DemoBanner(info: DemoInfo, signInPage: String?, modifier: Modifier = Modifier) {
val wipe = info.resetsAt
?.let { stringResource(R.string.demo_banner_wipe_at, it) }
?: stringResource(R.string.demo_banner_wipe_soon)
val logins = info.usernames.takeIf { it.isNotEmpty() }?.joinToString(" / ")?.let { names ->
if (signInPage != null) {
stringResource(R.string.demo_banner_logins_at, names, signInPage)
} else {
stringResource(R.string.demo_banner_logins, names)
}
}
val title = stringResource(R.string.demo_banner_title)
val accent = MaterialTheme.colorScheme.primary
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = buildAnnotatedString {
withStyle(SpanStyle(color = accent, fontWeight = FontWeight.Bold)) { append(title) }
append(wipe)
logins?.let { append(" $it") }
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant)
.padding(horizontal = 16.dp, vertical = 8.dp),
)
HorizontalDivider(thickness = 1.dp, color = accent)
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.demo
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.HttpUrl
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
/**
* What the Home strip says on the public demo (`docs/website/DEMO_MODE.md` §6).
*
* @property resetsAt when the next wipe runs, already worded ("14:00 UTC"), or null
* when the server sent no readable time.
* @property usernames the published logins, in the server's order. Their passwords
* are on the site's sign-in page, not in the app (DM20).
*/
data class DemoInfo(
val resetsAt: String?,
val usernames: List<String>,
) {
companion object {
/**
* The banner data from `GET /public/settings`' raw `demo` value, or null when
* there is none, which is every real shard. Forgiving field by field: a
* missing time or a malformed login drops that part, never the banner.
*/
fun from(demo: JsonElement?): DemoInfo? {
val obj = demo as? JsonObject ?: return null
val resetsAt = (obj["resetsAt"] as? JsonPrimitive)
?.takeIf { it.isString }
?.let { formatResetTime(it.content) }
val usernames = (obj["logins"] as? JsonArray).orEmpty().mapNotNull { login ->
((login as? JsonObject)?.get("username") as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.takeIf { it.isNotBlank() }
}
return DemoInfo(resetsAt, usernames)
}
}
}
private val RESET_TIME: DateTimeFormatter =
DateTimeFormatter.ofPattern("HH:mm 'UTC'").withZone(ZoneOffset.UTC)
/**
* "14:00 UTC" for an ISO-8601 instant, or null when it does not parse. The wipe runs
* on the hour in UTC (DM7), so the strip says UTC rather than converting, the same as
* the web's: a time in the viewer's own zone reads as a different schedule from the
* one published on `/beta`.
*/
fun formatResetTime(iso: String): String? = try {
RESET_TIME.format(Instant.parse(iso))
} catch (_: DateTimeParseException) {
null
}
/**
* The site's sign-in page as the strip prints it, `demo.runicgateway.com/account/login`:
* no scheme, and the port only when it is not the scheme's default.
*/
fun signInPageLabel(base: HttpUrl): String? {
val page = base.resolve("account/login") ?: return null
val port = if (page.port == HttpUrl.defaultPort(page.scheme)) "" else ":${page.port}"
return page.host + port + page.encodedPath
}

View File

@@ -0,0 +1,22 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.demo
import androidx.lifecycle.ViewModel
import com.runicgateway.app.core.net.DemoNotices
import com.runicgateway.app.core.result.DemoRefusal
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharedFlow
import javax.inject.Inject
/**
* Hands the shell the public demo's refusals (DM19), for its one snackbar. Only a
* window onto [DemoNotices]: the shell collects while it is on screen.
*/
@HiltViewModel
class DemoNoticeViewModel @Inject constructor(
notices: DemoNotices,
) : ViewModel() {
val refusals: SharedFlow<DemoRefusal> = notices.refusals
}

View File

@@ -32,6 +32,7 @@ import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.OnlineDot
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.demo.DemoBanner
/**
* Home / status (PLAN.md §6.1): the shard's name + tagline from branding, and a
@@ -44,11 +45,18 @@ fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val demo by viewModel.demo.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> HomeContent(brand, s.data, modifier)
Column(modifier = modifier.fillMaxSize()) {
// The public demo's strip (DEMO_MODE.md §6), above whatever the status read
// came back with, so it shows even while the status is loading or failed.
demo?.let { DemoBanner(info = it, signInPage = viewModel.signInPage) }
val content = Modifier.weight(1f)
when (val s = state) {
is UiState.Loading -> LoadingView(content)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = content)
is UiState.Success -> HomeContent(brand, s.data, content)
}
}
}

View File

@@ -5,9 +5,13 @@ package com.runicgateway.app.ui.home
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.demo.DemoInfo
import com.runicgateway.app.ui.demo.signInPageLabel
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -16,15 +20,27 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/** Loads site status (mode + version) for the Home screen (PLAN.md §6.1). */
/**
* Loads site status (mode + version) for the Home screen (PLAN.md §6.1), and the
* public demo's banner data when the site is one (`docs/website/DEMO_MODE.md` §6).
*/
@HiltViewModel
class HomeViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<StatusDto>>(UiState.Loading)
val state: StateFlow<UiState<StatusDto>> = _state.asStateFlow()
private val _demo = MutableStateFlow<DemoInfo?>(null)
/** The demo strip's data; null on every real shard, and while it is unknown. */
val demo: StateFlow<DemoInfo?> = _demo.asStateFlow()
/** The site's sign-in page, for the strip's logins sentence; null if no site is set. */
val signInPage: String? get() = baseUrlHolder.current?.let(::signInPageLabel)
init { load() }
fun load() {
@@ -32,5 +48,11 @@ class HomeViewModel @Inject constructor(
viewModelScope.launch {
_state.value = settingsRepository.getStatus().toUiState()
}
// A separate read, and silent: the strip is a nicety, so a failed settings
// call keeps whatever was shown rather than surfacing an error of its own.
viewModelScope.launch {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
_demo.value = DemoInfo.from(settings.demo)
}
}
}

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.isDemoLocked
import com.runicgateway.app.core.result.map
import com.runicgateway.app.data.api.dto.RosterCharDto
import com.runicgateway.app.data.repository.PlayerShardRepository
@@ -114,7 +115,7 @@ class CharactersViewModel @Inject constructor(
load()
}
is ApiResult.HttpError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result.status)))
it.copy(busy = false, feedback = Feedback(false, createErrorRes(result)))
}
is ApiResult.NetworkError -> _state.update {
it.copy(busy = false, feedback = Feedback(false, R.string.error_network))
@@ -129,12 +130,15 @@ class CharactersViewModel @Inject constructor(
else -> R.string.player_link_error
}
private fun createErrorRes(status: Int): Int = when (status) {
409 -> R.string.player_create_taken
429 -> R.string.player_create_capped
403 -> R.string.player_create_unavailable
400 -> R.string.player_create_rejected
503 -> R.string.error_shard_offline
// The demo's refusal comes first: its 403 would otherwise read as "signup is off
// on this shard", which is not what refused it (DEMO_MODE.md §3.1).
private fun createErrorRes(result: ApiResult.HttpError): Int = when {
result.isDemoLocked() -> R.string.error_demo_locked
result.status == 409 -> R.string.player_create_taken
result.status == 429 -> R.string.player_create_capped
result.status == 403 -> R.string.player_create_unavailable
result.status == 400 -> R.string.player_create_rejected
result.status == 503 -> R.string.error_shard_offline
else -> R.string.player_create_error
}
}

View File

@@ -23,6 +23,15 @@
<string name="error_shard_offline">The game server is offline right now.</string>
<string name="error_feature_unavailable">This site doesn\'t publish this here.</string>
<string name="error_server">Something went wrong on the server. Please try again.</string>
<!-- The public demo's refusal (DEMO_MODE.md §3.1): the server's own words, used when its answer carries none. -->
<string name="error_demo_locked">This is turned off on the demo site.</string>
<!-- The public demo's strip on Home (DEMO_MODE.md §6). The time is said in UTC, like the web's. -->
<string name="demo_banner_title">Demo site</string>
<string name="demo_banner_wipe_at">" — anything you change is wiped at %1$s."</string>
<string name="demo_banner_wipe_soon">" — anything you change is wiped every two hours."</string>
<string name="demo_banner_logins_at">Sign-ins: %1$s — passwords at %2$s.</string>
<string name="demo_banner_logins">Sign-ins: %1$s — passwords on the site\'s sign-in page.</string>
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
<string name="connect_title">Connect to your community</string>

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import com.runicgateway.app.core.result.DemoRefusal
import com.runicgateway.app.util.DEMO_LOCKED_BODY
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The main client's refusal spotter (DEMO_MODE.md §3.1, DM19). Runs a real OkHttp
* client whose last interceptor answers in place of the network.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class DemoLockInterceptorTest {
private val notices = DemoNotices()
private fun clientAnswering(code: Int, body: String): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(DemoLockInterceptor(notices))
.addInterceptor(Interceptor { chain ->
Response.Builder()
.request(chain.request())
.protocol(Protocol.HTTP_1_1)
.code(code)
.message("")
.body(body.toResponseBody("application/json".toMediaType()))
.build()
})
.build()
private fun call(client: OkHttpClient): String =
client.newCall(Request.Builder().url("https://demo.example/api/v1/x").build())
.execute()
.use { it.body!!.string() }
@Test fun aRefusalIsReportedAndTheBodyStillReachesTheCaller() = runTest(UnconfinedTestDispatcher()) {
val seen = mutableListOf<DemoRefusal>()
backgroundScope.launch { notices.refusals.collect { seen += it } }
val body = call(clientAnswering(403, DEMO_LOCKED_BODY))
assertEquals(listOf(DemoRefusal("This is turned off on the demo site.")), seen)
// Peeked, not consumed: Retrofit still reads the same body into its error.
assertEquals(DEMO_LOCKED_BODY, body)
}
@Test fun anythingElseIsNotReported() = runTest(UnconfinedTestDispatcher()) {
val seen = mutableListOf<DemoRefusal>()
backgroundScope.launch { notices.refusals.collect { seen += it } }
call(clientAnswering(403, """{"message":"Admins only"}"""))
call(clientAnswering(403, "<html>Forbidden</html>"))
call(clientAnswering(500, DEMO_LOCKED_BODY))
call(clientAnswering(200, DEMO_LOCKED_BODY))
assertTrue(seen.isEmpty())
}
}

View File

@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import com.runicgateway.app.util.DEMO_LOCKED_BODY
import com.runicgateway.app.util.demoLockedError
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
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 retrofit2.HttpException
import retrofit2.Response
/** Reading the public demo's refusal off an error body (DEMO_MODE.md §3.1). */
class DemoLockedTest {
@Test fun theServersBodyIsARefusalWithItsMessage() {
assertEquals(DemoRefusal("This is turned off on the demo site."), demoRefusalOf(DEMO_LOCKED_BODY))
}
@Test fun aModulesOwnSentenceIsKept() {
// A refused console command names the command (§3.4); the app must not
// swap that for the generic line.
val body = """{"message":"The command quit is turned off on the demo site.","code":"DEMO_LOCKED"}"""
assertEquals("The command quit is turned off on the demo site.", demoRefusalOf(body)!!.message)
}
@Test fun aRefusalWithNoMessageStillCounts() {
assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED"}"""))
assertEquals(DemoRefusal(null), demoRefusalOf("""{"code":"DEMO_LOCKED","message":" "}"""))
}
@Test fun anythingElseIsNotARefusal() {
assertNull(demoRefusalOf(null))
assertNull(demoRefusalOf(""))
assertNull(demoRefusalOf("{}"))
assertNull(demoRefusalOf("""{"message":"Forbidden"}"""))
assertNull(demoRefusalOf("""{"code":"FORBIDDEN"}"""))
assertNull(demoRefusalOf("""{"code":7}"""))
assertNull(demoRefusalOf("""["DEMO_LOCKED"]"""))
assertNull(demoRefusalOf("<html>403 Forbidden</html>"))
}
@Test fun safeApiCallCarriesTheCode() = runTest {
val result = safeApiCall { throw demoLockedError() }
assertEquals(DEMO_LOCKED_CODE, (result as ApiResult.HttpError).code)
assertTrue(result.isDemoLocked())
}
@Test fun anOrdinary403IsNotARefusal() = runTest {
val result = safeApiCall {
throw HttpException(Response.error<Any>(403, """{"message":"Admins only"}""".toResponseBody(null)))
}
assertEquals(403, (result as ApiResult.HttpError).status)
assertNull(result.code)
assertFalse(result.isDemoLocked())
}
@Test fun onlyA403sBodyIsRead() = runTest {
// The server answers the refusal with 403 and nothing else, so another
// status's body is never buffered, even if it happens to carry the code.
val result = safeApiCall {
throw HttpException(Response.error<Any>(500, DEMO_LOCKED_BODY.toResponseBody(null)))
}
assertNull((result as ApiResult.HttpError).code)
assertFalse(result.isDemoLocked())
}
@Test fun otherResultsAreNeverRefusals() {
assertFalse(ApiResult.Ok(1).isDemoLocked())
assertFalse(ApiResult.NetworkError(java.io.IOException()).isDemoLocked())
assertFalse(ApiResult.HttpError(403).isDemoLocked())
}
}

View File

@@ -4,8 +4,10 @@
package com.runicgateway.app.ui
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
@@ -13,6 +15,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.demo.DemoInfo
import com.runicgateway.app.ui.home.HomeViewModel
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.news.NewsViewModel
@@ -22,7 +25,10 @@ import com.runicgateway.app.ui.wiki.WikiPageViewModel
import com.runicgateway.app.ui.wiki.WikiViewModel
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.serialization.json.Json
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -128,13 +134,33 @@ class ContentViewModelTest {
// ── Home status ─────────────────────────────────────────────────────────
@Test fun homeLoadsStatus() {
api.status = StatusDto(mode = "maintenance")
val vm = HomeViewModel(settings)
val vm = homeViewModel()
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
}
@Test fun homeNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
val state = HomeViewModel(settings).state.value
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
val vm = homeViewModel()
assertEquals(ErrorKind.NETWORK, (vm.state.value as UiState.Error).kind)
assertNull(vm.demo.value)
}
// ── Home: the public demo's strip (DEMO_MODE.md §6) ──
@Test fun homeDrawsNoStripOnARealShard() {
assertNull(homeViewModel().demo.value)
}
@Test fun homeReadsTheDemoBlock() {
api.settings = SettingsDto(
demo = Json.parseToJsonElement(
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[{"username":"demo-admin","role":"admin"}]}""",
),
)
val vm = homeViewModel(base = "https://demo.runicgateway.com/")
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin")), vm.demo.value)
assertEquals("demo.runicgateway.com/account/login", vm.signInPage)
}
private fun homeViewModel(base: String? = null) =
HomeViewModel(settings, BaseUrlHolder().apply { set(base?.toHttpUrl()) })
}

View File

@@ -4,6 +4,7 @@
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.DEMO_LOCKED_CODE
import com.runicgateway.app.ui.components.isRetryable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -64,13 +65,30 @@ class UiStateTest {
@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 }) {
// the user could wait out. The demo's refusal is fixed the same way.
val fixed = setOf(ErrorKind.FEATURE_UNAVAILABLE, ErrorKind.DEMO_LOCKED)
for (kind in fixed) assertFalse("$kind should not offer a retry", isRetryable(kind))
for (kind in ErrorKind.entries.filter { it !in fixed }) {
assertTrue("$kind should offer a retry", isRetryable(kind))
}
}
// ── The public demo's refusal (DEMO_MODE.md §3.1) ──
@Test fun theDemosRefusalIsItsOwnKind() {
val refused = ApiResult.HttpError(403, code = DEMO_LOCKED_CODE)
assertEquals(ErrorKind.DEMO_LOCKED, (refused.toUiState() as UiState.Error).kind)
assertEquals(403, (refused.toUiState() as UiState.Error).httpStatus)
// On a shard read a 403 would otherwise mean "not published to you".
assertEquals(ErrorKind.DEMO_LOCKED, (refused.toShardUiState() as UiState.Error).kind)
}
@Test fun anotherCodeLeavesTheStatusMapping() {
val other = ApiResult.HttpError(403, code = "FORBIDDEN")
assertEquals(ErrorKind.SERVER, (other.toUiState() as UiState.Error).kind)
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (other.toShardUiState() as UiState.Error).kind)
}
private fun kindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind

View File

@@ -11,6 +11,8 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedError
import com.runicgateway.app.util.demoLockedUnit
import com.runicgateway.app.util.errorUnit
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
@@ -63,6 +65,15 @@ class AdminContentViewModelTest {
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
@Test fun theDemosRefusalIsNotBlamedOnTheRole() {
// Somebody signed in as demo-admin must not read "you don't have permission".
api.unitResponse = demoLockedUnit()
api.error = demoLockedError()
val vm = viewModel()
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
}
@Test fun createCategoryRejectsBlankFields() {
val vm = viewModel()
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)

View File

@@ -11,6 +11,7 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedError
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -60,6 +61,16 @@ class AdminDashboardViewModelTest {
assertEquals(R.string.admin_forbidden, fb.messageRes)
}
@Test fun setSiteModeOnTheDemoShowsTheDemoLine() {
api.dashboard = AdminDashboardDto()
api.error = demoLockedError()
val vm = viewModel()
vm.setSiteMode("maintenance")
val fb = vm.state.value.feedback!!
assertFalse(fb.ok)
assertEquals(R.string.error_demo_locked, fb.messageRes)
}
@Test fun clearFeedbackResetsBanner() {
api.error = httpError(500)
val vm = viewModel()

View File

@@ -7,6 +7,7 @@ import com.runicgateway.app.R
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedUnit
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -48,6 +49,13 @@ class AdminModerationViewModelTest {
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
}
@Test fun kickOnTheDemoShowsTheDemoLine() {
api.unitResponse = demoLockedUnit()
val vm = viewModel()
vm.kick(account = "x", serial = "")
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
}
@Test fun broadcastRejectsBlankText() {
val vm = viewModel()
vm.broadcast(text = " ", hue = null)

View File

@@ -9,6 +9,7 @@ import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedUnit
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -52,6 +53,13 @@ class AdminSupportViewModelTest {
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
}
@Test fun closeOnTheDemoShowsTheDemoLine() {
api.unitResponse = demoLockedUnit()
val vm = viewModel()
vm.close("0x1")
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
}
@Test fun closeShardOfflineMapsTo503Copy() {
api.unitResponse = errorUnit(503)
val vm = viewModel()

View File

@@ -8,6 +8,7 @@ import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContactRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedError
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
@@ -64,4 +65,14 @@ class ContactViewModelTest {
assertTrue(result is ContactViewModel.Result.Failed)
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
}
@Test fun theDemosLockedContactFormSaysSo() {
// The contact form is locked on the demo (DEMO_MODE.md §3.3).
api.error = demoLockedError()
val vm = viewModel()
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
vm.send()
val result = vm.state.value.result as ContactViewModel.Result.Failed
assertEquals(ErrorKind.DEMO_LOCKED, result.kind)
}
}

View File

@@ -0,0 +1,58 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.demo
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** The Home strip's data (DEMO_MODE.md §6). */
class DemoInfoTest {
private fun parse(json: String) = DemoInfo.from(Json.parseToJsonElement(json))
@Test fun readsTheServersShape() {
val info = parse(
"""{"resetsAt":"2026-10-26T14:00:00.000Z","logins":[
{"username":"demo-admin","password":"x","role":"admin"},
{"username":"demo-player","password":"y","role":"player"}]}""",
)
assertEquals(DemoInfo("14:00 UTC", listOf("demo-admin", "demo-player")), info)
}
@Test fun noBlockMeansNoBanner() {
assertNull(DemoInfo.from(null))
assertNull(DemoInfo.from(JsonNull))
assertNull(parse("""["demo"]"""))
}
@Test fun aBadPartDropsThatPartOnly() {
assertEquals(
DemoInfo(null, listOf("demo-admin")),
parse("""{"resetsAt":"soon","logins":[{"username":"demo-admin"},{"username":""},{"name":"x"},7]}"""),
)
assertEquals(DemoInfo(null, emptyList()), parse("""{"resetsAt":42,"logins":"demo-admin"}"""))
assertEquals(DemoInfo(null, emptyList()), parse("{}"))
}
@Test fun theTimeIsSaidInUtc() {
// Not converted to the phone's zone: it must match the schedule on /beta.
assertEquals("14:00 UTC", formatResetTime("2026-10-26T14:00:00Z"))
assertEquals("00:05 UTC", formatResetTime("2026-10-26T00:05:59.999Z"))
assertNull(formatResetTime("14:00"))
assertNull(formatResetTime(""))
}
@Test fun theSignInPageIsPrintedWithoutTheScheme() {
assertEquals(
"demo.runicgateway.com/account/login",
signInPageLabel("https://demo.runicgateway.com/".toHttpUrl()),
)
assertEquals("10.0.2.2:3000/account/login", signInPageLabel("http://10.0.2.2:3000/".toHttpUrl()))
assertEquals("example.com/site/account/login", signInPageLabel("https://example.com/site/".toHttpUrl()))
}
}

View File

@@ -15,6 +15,7 @@ import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.demoLockedError
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -86,4 +87,11 @@ class CharactersViewModelTest {
vm.createAccount(account = "acct1", password = "longenough")
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
}
@Test fun createAccountOnTheDemoIsNotSignupBeingOff() {
playerApi.error = demoLockedError()
val vm = viewModel()
vm.createAccount(account = "acct1", password = "longenough")
assertEquals(R.string.error_demo_locked, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -25,3 +25,14 @@ fun okUnit(): Response<Unit> = Response.success(Unit)
/** A non-2xx bodyless [Response] with [code]. */
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))
/** The public demo's refusal body (DEMO_MODE.md §3.1), as the server sends it. */
const val DEMO_LOCKED_BODY =
"""{"message":"This is turned off on the demo site.","code":"DEMO_LOCKED"}"""
/** The [HttpException] a fake API throws for a write the public demo refuses. */
fun demoLockedError(): HttpException =
HttpException(Response.error<Any>(403, DEMO_LOCKED_BODY.toResponseBody(JSON)))
/** The `Response<Unit>` of a write the public demo refuses. */
fun demoLockedUnit(): Response<Unit> = Response.error(403, DEMO_LOCKED_BODY.toResponseBody(JSON))