All checks were successful
PR Checks / android-build (pull_request) Successful in 6m20s
M11 Part 1 (docs/android/PLAN.md §9). The website's Protocol 3.0 work made every
shard-derived surface admin-configurable — a feature can be switched off, or its
audience raised above the caller's rung — and the app knew nothing about it: it
gated shard navigation on the session role alone, so an admin change left the
drawer and the hub offering entries that 404/403 into a generic error where the
web client hides them.
The visibility rules:
- GET /public/shard/features behind a singleton ShardFeaturesRepository,
re-resolved on every session change (the answer is per-viewer) and dropped on
a Settings → Server switch, which is the one case no session change covers.
- MenuEntry gains `feature` beside `access`; the two gates are independent and
both must pass. ShardBoard tags each hub tile the same way.
- An unknown answer FAILS OPEN, matching lib/useShardFeatures.js: the server
gates every call regardless, so a link that briefly 403s beats a drawer that
flickers its entries in on every cold start. A pre-3.0 website 404s this
route, which reads as "unknown" and behaves exactly as before.
- toShardUiState() maps 404 AND 403 to a new ErrorKind.FEATURE_UNAVAILABLE:
requireFeature answers 404 for a disabled feature (deliberately not
disclosing it exists) and 403 for a viewer below its rung. Kept separate from
toUiState() because both statuses mean something else off the shard surface —
a deleted post, an ownership refusal. That state renders without a retry
button; an admin controls it, so retrying cannot change the answer.
The read-model adds, from the same v3 series:
- char.profile `points` — the Loyalty & Points block. maxPoints 0 means
UNCAPPED and is the common case, so nothing divides by it and only a capped
system gets a meter; nameString is usually null (systems name themselves with
a cliloc) so humanising the PointsType key is the primary display path; rank
is absent unless the shard opts in, and absent is not "unranked".
- Cliloc-resolved names — equipment `clilocName` and titles `rewardResolved`,
so items stop rendering as a layer. rewardResolved is positional: an entry
the table could not resolve is null and is skipped WITHOUT shifting the
`selected` index onto its neighbour.
ActorDto keeps acct/webId but documents them as admin-locked rather than
available. Points ride ungated on /player/shard/char/:serial — a character's own
standings are self-service and do not depend on the public leaderboards feature,
so the app mirrors that rather than re-gating it.
304 unit tests pass; lint clean.
Co-Authored-By: Claude <noreply@anthropic.com>
80 lines
3.1 KiB
Kotlin
80 lines
3.1 KiB
Kotlin
/*
|
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
|
*/
|
|
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
|
|
|
|
/** Unit tests for folding an [ApiResult] into a screen [UiState] (PLAN.md §7). */
|
|
class UiStateTest {
|
|
|
|
@Test fun okBecomesSuccess() {
|
|
assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toUiState())
|
|
}
|
|
|
|
@Test fun networkErrorMapsToNetworkKind() {
|
|
val state = ApiResult.NetworkError(IOException()).toUiState()
|
|
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
|
|
}
|
|
|
|
@Test fun knownStatusesMapToKinds() {
|
|
assertEquals(ErrorKind.NOT_FOUND, kindOf(404))
|
|
assertEquals(ErrorKind.RATE_LIMITED, kindOf(429))
|
|
assertEquals(ErrorKind.SHARD_OFFLINE, kindOf(503))
|
|
assertEquals(ErrorKind.SERVER, kindOf(500))
|
|
}
|
|
|
|
@Test fun httpStatusIsPreserved() {
|
|
val state = ApiResult.HttpError(503).toUiState() as UiState.Error
|
|
assertEquals(503, state.httpStatus)
|
|
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
|
|
}
|