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

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:
2026-07-30 02:34:41 -05:00
parent 4fe7a7e2a3
commit 833e51de69
26 changed files with 915 additions and 51 deletions

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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),
)
}
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()))
}
}

View File

@@ -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)
}
}