Files
Android-app/app/src/test/java/com/runicgateway/app/ui/UiStateTest.kt
wtclaude 833e51de69
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m20s
feat(shard): follow the visibility framework and read the Protocol 3.0 profile
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>
2026-07-30 02:34:41 -05:00

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
}