feat(shard): follow the visibility framework and read the Protocol 3.0 profile
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m20s
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>
This commit is contained in:
@@ -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