feat(shard): the four Protocol 3.0 content screens
M11 Part 2 (docs/android/PLAN.md §9), on the visibility plumbing Part 1 added.
Each screen hides from the menu when the shard doesn't publish its feature, and
self-reports "not available here" from its own 404/403 so a deep link still
lands on an honest answer.
- Rules (/public/shard/ruleset). A null body means the shard has never
published a ruleset, which is a SUCCESS state, not the feature being off —
the screen tells the two apart. Blocks render only when published, since an
omitted block means the system is off rather than unknown. Skill caps are
converted out of tenths; the raw 1000 reads as ten times the real limit.
Live via world.ruleset, which the shard re-emits on every reconnect.
- Leaderboards (/public/shard/points). Boards order most-contested first, live
via points.board. maxPoints 0 is uncapped so no cap line is drawn, and a
cliloc-named board (nameString null, the usual case) falls back to the
humanised PointsType key. A nameless rank is a valid row: the character name
is the feature's one admin-configurable field.
- Market (/public/shard/market + /meta + /vendors/:serial). NOT live: the
market feature ships with its SSE fan-out disabled, so this is a plain
paginated read, searched on submit rather than per keystroke because it is
the site's first rate-limited public endpoint. The staleness line is
required, not decoration — the round-robin sweep means a price can be a full
cycle old. The vendor screen is the only surface that can render a truncated
shop and a gated location, the latter as a real answer rather than a blank
coordinate.
- Atlas (/public/atlas/creatures[/:slug]). Static shard content, so it stays
readable while the shard is down — but site-mode gated, unlike /shard/*.
Rows lead with the server's placement label ("Despise, Felucca"), which is
the transform the whole feature exists for. Respawn delays are read as
SECONDS, the unit the parser normalises XmlSpawner's mixed minutes/seconds
into. Facet filter options are discovered from the shard's own data — nothing
here names a facet, since a shard may add, replace or rename them.
336 unit tests pass (32 new); lint clean. The five-rung on-device walk runs
against a local website on the cutover branch before the cutover merges.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,14 @@ 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.AtlasCreatureDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasMetaDto
|
||||
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
|
||||
import com.runicgateway.app.data.api.dto.ShardStatusDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
@@ -59,6 +67,24 @@ class FakePublicApi : PublicApi {
|
||||
var houses: List<HouseDto> = emptyList()
|
||||
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
|
||||
|
||||
// Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the
|
||||
// shard has never published one, which is a success, not a failure.
|
||||
var ruleset: RulesetDto? = null
|
||||
var pointsBoards: List<PointsBoardDto> = emptyList()
|
||||
var pointsBoard: PointsBoardDto = PointsBoardDto()
|
||||
var market: MarketPageDto = MarketPageDto()
|
||||
var marketMeta: MarketMetaDto = MarketMetaDto()
|
||||
var marketVendor: MarketVendorDto = MarketVendorDto()
|
||||
var atlasCreatures: AtlasCreaturePageDto = AtlasCreaturePageDto()
|
||||
var atlasCreature: AtlasCreatureDto = AtlasCreatureDto()
|
||||
var atlasMeta: AtlasMetaDto = AtlasMetaDto()
|
||||
|
||||
/** Last market query seen, so a test can assert blanks were dropped. */
|
||||
var lastMarketQuery: String? = null
|
||||
|
||||
/** Last atlas facet filter seen. */
|
||||
var lastAtlasFacet: String? = null
|
||||
|
||||
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
|
||||
var lastContact: ContactRequest? = null
|
||||
|
||||
@@ -87,6 +113,40 @@ class FakePublicApi : PublicApi {
|
||||
}
|
||||
|
||||
override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures)
|
||||
override suspend fun getShardRuleset(): RulesetDto? = reply(ruleset)
|
||||
override suspend fun getShardPoints(): List<PointsBoardDto> = reply(pointsBoards)
|
||||
override suspend fun getShardPointsBoard(system: String): PointsBoardDto = reply(pointsBoard)
|
||||
|
||||
override suspend fun getShardMarket(
|
||||
query: String?,
|
||||
minPrice: Long?,
|
||||
maxPrice: Long?,
|
||||
map: String?,
|
||||
region: String?,
|
||||
sort: String?,
|
||||
limit: Int?,
|
||||
offset: Int?,
|
||||
): MarketPageDto {
|
||||
lastMarketQuery = query
|
||||
return reply(market)
|
||||
}
|
||||
|
||||
override suspend fun getShardMarketMeta(): MarketMetaDto = reply(marketMeta)
|
||||
override suspend fun getShardMarketVendor(serial: String, limit: Int?, offset: Int?): MarketVendorDto =
|
||||
reply(marketVendor)
|
||||
|
||||
override suspend fun getAtlasCreatures(
|
||||
query: String?,
|
||||
facet: String?,
|
||||
limit: Int?,
|
||||
offset: Int?,
|
||||
): AtlasCreaturePageDto {
|
||||
lastAtlasFacet = facet
|
||||
return reply(atlasCreatures)
|
||||
}
|
||||
|
||||
override suspend fun getAtlasCreature(slug: String): AtlasCreatureDto = reply(atlasCreature)
|
||||
override suspend fun getAtlasMeta(): AtlasMetaDto = reply(atlasMeta)
|
||||
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,172 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasSpawnerDto
|
||||
import com.runicgateway.app.data.api.dto.MarketListingDto
|
||||
import com.runicgateway.app.data.api.dto.MarketLocationDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The pure display helpers behind the four Protocol 3.0 screens (PLAN.md §9 M11).
|
||||
* Each one exists because the raw wire value would be wrong or misleading on screen —
|
||||
* units in tenths, delays in seconds, an "uncapped" cap of zero, a gated field.
|
||||
*/
|
||||
class ShardContentHelpersTest {
|
||||
|
||||
// ── Rules (§5) ───────────────────────────────────────────────────────
|
||||
|
||||
@Test fun skillCapsConvertOutOfTenths() {
|
||||
// 1000 is 100.0. Showing the raw number reads as a shard with ten times the
|
||||
// usual limit, which is worse than showing nothing.
|
||||
assertEquals("100", formatSkillCap(1000 / 10.0))
|
||||
assertEquals("72.5", formatSkillCap(725 / 10.0))
|
||||
}
|
||||
|
||||
@Test fun systemKeysHumanise() {
|
||||
// Word boundaries become spaces and the inner capital is kept, matching the
|
||||
// website's `humanise` — "City Loyalty" is the system's actual name.
|
||||
assertEquals("City Loyalty", humaniseSystem("cityLoyalty"))
|
||||
assertEquals("Vvv", humaniseSystem("vvv"))
|
||||
assertEquals("Treasure Maps", humaniseSystem("treasureMaps"))
|
||||
}
|
||||
|
||||
@Test fun theRestartScheduleOnlyShowsWhenTheShardRunsOne() {
|
||||
assertEquals("04:30", formatRestart(enabled = true, hour = 4, minute = 30))
|
||||
assertEquals("04:00", formatRestart(enabled = true, hour = 4, minute = null))
|
||||
assertNull(formatRestart(enabled = false, hour = 4, minute = 30))
|
||||
assertNull(formatRestart(enabled = true, hour = null, minute = 30))
|
||||
}
|
||||
|
||||
// ── Leaderboards (§7) ────────────────────────────────────────────────
|
||||
|
||||
@Test fun boardLabelFallsBackToTheHumanisedKey() {
|
||||
// The PRIMARY path: four of five boards on a real shard name themselves with a
|
||||
// cliloc and send nameString null.
|
||||
assertEquals("Queens Loyalty", boardLabel(PointsBoardDto(system = "QueensLoyalty")))
|
||||
assertEquals(
|
||||
"Queen's Loyalty",
|
||||
boardLabel(PointsBoardDto(system = "QueensLoyalty", nameString = "Queen's Loyalty")),
|
||||
)
|
||||
assertEquals("Clean Up Britannia", boardLabel(PointsBoardDto(system = "CleanUpBritannia", nameString = " ")))
|
||||
}
|
||||
|
||||
@Test fun anUncappedBoardReportsNoCap() {
|
||||
assertNull(PointsBoardDto(maxPoints = 0).cap)
|
||||
assertEquals(30000L, PointsBoardDto(maxPoints = 30000).cap)
|
||||
}
|
||||
|
||||
@Test fun boardsOrderByContestedThenName() {
|
||||
val boards = listOf(
|
||||
PointsBoardDto(system = "Quiet", players = 2),
|
||||
PointsBoardDto(system = "Busy", players = 900),
|
||||
PointsBoardDto(system = "AlsoQuiet", players = 2),
|
||||
)
|
||||
assertEquals(listOf("Busy", "Also Quiet", "Quiet"), orderBoards(boards).map { boardLabel(it) })
|
||||
}
|
||||
|
||||
@Test fun boardsTheShardHidesFromItsOwnGumpAreDropped() {
|
||||
// `showOnGump` is the shard's own "is this player-facing?" signal.
|
||||
val boards = listOf(
|
||||
PointsBoardDto(system = "Shown", players = 1, showOnGump = true),
|
||||
PointsBoardDto(system = "Internal", players = 99, showOnGump = false),
|
||||
)
|
||||
assertEquals(listOf("Shown"), orderBoards(boards).map { it.system })
|
||||
}
|
||||
|
||||
// ── Market (§8) ──────────────────────────────────────────────────────
|
||||
|
||||
@Test fun listingTitlePrefersAPlayerNameThenTheResolvedOne() {
|
||||
assertEquals("Bob's axe", listingTitle(MarketListingDto(name = "Bob's axe", displayName = "hatchet")))
|
||||
assertEquals("hatchet", listingTitle(MarketListingDto(displayName = "hatchet")))
|
||||
}
|
||||
|
||||
@Test fun listingTitleIsNullWithoutAnyName() {
|
||||
// A shard with no cliloc table configured publishes neither, and the screen
|
||||
// falls back to the item id rather than inventing a label.
|
||||
assertNull(listingTitle(MarketListingDto(itemId = 3922)))
|
||||
}
|
||||
|
||||
@Test fun aStackShowsItsCount() {
|
||||
// "12 × ingot" and "ingot" at the same price are very different offers.
|
||||
assertEquals("12 × ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = 12)))
|
||||
assertEquals("ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = 1)))
|
||||
assertEquals("ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = null)))
|
||||
}
|
||||
|
||||
@Test fun locationPrefersTheHouseThenTheRegion() {
|
||||
assertEquals(
|
||||
"Darrow's Tower, Felucca",
|
||||
locationLine(MarketLocationDto(map = "Felucca", region = "Britain", house = "Darrow's Tower")),
|
||||
)
|
||||
assertEquals("Britain, Felucca", locationLine(MarketLocationDto(map = "Felucca", region = "Britain")))
|
||||
assertEquals("Felucca", locationLine(MarketLocationDto(map = "Felucca")))
|
||||
}
|
||||
|
||||
@Test fun aGatedLocationIsNullRatherThanAHalfAnswer() {
|
||||
// The block is nested precisely so one admin rule takes the facet, the
|
||||
// coordinates, the region and the house together — there is no partial state
|
||||
// to render.
|
||||
assertNull(locationLine(null))
|
||||
assertNull(locationLine(MarketLocationDto(x = 100, y = 200)))
|
||||
}
|
||||
|
||||
// ── Atlas (§6) ───────────────────────────────────────────────────────
|
||||
|
||||
@Test fun respawnDelaysAreReadAsSeconds() {
|
||||
// The API normalises XmlSpawner's mixed minutes/seconds, so these ARE seconds.
|
||||
assertEquals("30s", formatRespawn(30, 30))
|
||||
assertEquals("5m", formatRespawn(300, 300))
|
||||
assertEquals("5m–10m", formatRespawn(300, 600))
|
||||
assertEquals("1m 30s", formatRespawn(90, 90))
|
||||
}
|
||||
|
||||
@Test fun aHalfSpecifiedRespawnStillReads() {
|
||||
assertEquals("5m", formatRespawn(300, null))
|
||||
assertEquals("5m", formatRespawn(null, 300))
|
||||
assertNull(formatRespawn(null, null))
|
||||
}
|
||||
|
||||
@Test fun spawnerPlacePrefersTheServersPlacementLabel() {
|
||||
// The point-in-rect transform is the reason this feature exists: it turns
|
||||
// "5411,1234" into "Despise, Felucca".
|
||||
val spawner = AtlasSpawnerDto(
|
||||
label = "Despise, Felucca",
|
||||
region = "Despise",
|
||||
facet = "Felucca",
|
||||
x = 5411,
|
||||
y = 1234,
|
||||
)
|
||||
assertEquals("Despise, Felucca", spawnerPlace(spawner))
|
||||
}
|
||||
|
||||
@Test fun spawnerPlaceFallsBackThroughRegionLandmarkThenCoordinates() {
|
||||
assertEquals(
|
||||
"Despise, Felucca",
|
||||
spawnerPlace(AtlasSpawnerDto(region = "Despise", facet = "Felucca")),
|
||||
)
|
||||
assertEquals(
|
||||
"Yew Crossroads, Trammel",
|
||||
spawnerPlace(AtlasSpawnerDto(landmark = "Yew Crossroads", facet = "Trammel")),
|
||||
)
|
||||
// ~17% of stock spawns resolve to no named place; coordinates are honest there.
|
||||
assertEquals(
|
||||
"Felucca 5411, 1234",
|
||||
spawnerPlace(AtlasSpawnerDto(facet = "Felucca", x = 5411, y = 1234)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun facetSummaryLeadsWithWhereItMostlyIs() {
|
||||
val creature = AtlasCreatureDto(
|
||||
slug = "lizardman",
|
||||
facets = mapOf("Trammel" to 4, "Felucca" to 30, "Ilshenar" to 12),
|
||||
)
|
||||
assertEquals("Felucca, Ilshenar, Trammel", facetSummary(creature))
|
||||
assertNull(facetSummary(AtlasCreatureDto(slug = "unique")))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.shard
|
||||
|
||||
import com.runicgateway.app.core.net.ShardStreamEvent
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
|
||||
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
|
||||
import com.runicgateway.app.data.api.dto.MarketListingDto
|
||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
import com.runicgateway.app.data.api.dto.RulesetDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.data.api.fake.FakeShardStream
|
||||
import com.runicgateway.app.data.repository.ShardRepository
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The four Protocol 3.0 content screens (PLAN.md §9 M11): loading, the live merge, and
|
||||
* the states that are easy to get wrong — "published nothing" vs "switched off", and a
|
||||
* gated feature reading as unavailable rather than as a fault.
|
||||
*/
|
||||
class ShardContentViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true }
|
||||
|
||||
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
|
||||
|
||||
// ── Rules ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun rulesLoadTheRuleset() {
|
||||
api.ruleset = RulesetDto(shard = "UOMysticmoon", expansion = "EJ")
|
||||
|
||||
val state = RulesViewModel(repo()).state.value
|
||||
|
||||
assertEquals("UOMysticmoon", (state as UiState.Success).data?.shard)
|
||||
}
|
||||
|
||||
@Test fun anUnpublishedRulesetIsASuccessWithNoBody() {
|
||||
// Distinct from the feature being switched off: the shard is reachable and
|
||||
// simply hasn't emitted world.ruleset yet.
|
||||
api.ruleset = null
|
||||
|
||||
val state = RulesViewModel(repo()).state.value
|
||||
|
||||
assertTrue(state is UiState.Success)
|
||||
assertNull((state as UiState.Success).data)
|
||||
}
|
||||
|
||||
@Test fun aGatedRulesetFeatureReadsAsUnavailableNotAsAFault() {
|
||||
api.error = httpError(404)
|
||||
|
||||
val state = RulesViewModel(repo()).state.value
|
||||
|
||||
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
@Test fun aLiveRulesetFrameReplacesTheLoadedCopy() {
|
||||
// The frame IS the whole ruleset — the shard re-emits it on every reconnect, so
|
||||
// a restart with edited config updates an open screen.
|
||||
api.ruleset = RulesetDto(shard = "Old", expansion = "EJ")
|
||||
val stream = FakeShardStream(
|
||||
listOf(
|
||||
ShardStreamEvent.Frame(
|
||||
"world.ruleset",
|
||||
buildJsonObject { put("shard", "New"); put("expansion", "EJ") },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val state = RulesViewModel(repo(stream)).state.value
|
||||
|
||||
assertEquals("New", (state as UiState.Success).data?.shard)
|
||||
}
|
||||
|
||||
// ── Leaderboards ─────────────────────────────────────────────────────
|
||||
|
||||
@Test fun leaderboardsSeedAndMergeLiveBoards() {
|
||||
api.pointsBoards = listOf(
|
||||
PointsBoardDto(system = "QueensLoyalty", players = 800),
|
||||
PointsBoardDto(system = "VoidPool", players = 10),
|
||||
)
|
||||
val stream = FakeShardStream(
|
||||
listOf(
|
||||
ShardStreamEvent.Frame(
|
||||
"points.board",
|
||||
buildJsonObject { put("system", "VoidPool"); put("players", 999) },
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val boards = (LeaderboardsViewModel(repo(stream)).state.value as UiState.Success).data
|
||||
|
||||
// The merged board overtook the seeded one on the contested ordering.
|
||||
assertEquals(listOf("VoidPool", "QueensLoyalty"), boards.map { it.system })
|
||||
}
|
||||
|
||||
@Test fun aGatedLeaderboardsFeatureReadsAsUnavailable() {
|
||||
api.error = httpError(403)
|
||||
|
||||
val state = LeaderboardsViewModel(repo()).state.value
|
||||
|
||||
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
// ── Market ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun marketLoadsListingsAndMeta() {
|
||||
api.market = MarketPageDto(
|
||||
listings = listOf(MarketListingDto(serial = "0x1", displayName = "hatchet", price = 250)),
|
||||
total = 1,
|
||||
)
|
||||
|
||||
val vm = MarketViewModel(repo())
|
||||
|
||||
assertEquals(1, (vm.state.value as UiState.Success).data.listings.size)
|
||||
}
|
||||
|
||||
@Test fun aBlankMarketQueryIsNotSentAsAnEmptyFilter() {
|
||||
MarketViewModel(repo())
|
||||
assertNull(api.lastMarketQuery)
|
||||
}
|
||||
|
||||
@Test fun theMarketQueryIsBoundedToWhatTheServerAccepts() {
|
||||
// Trimmed here rather than bounced as a 400.
|
||||
val vm = MarketViewModel(repo())
|
||||
vm.onQueryChange("x".repeat(200))
|
||||
assertEquals(MarketViewModel.MAX_QUERY, vm.query.value.length)
|
||||
}
|
||||
|
||||
@Test fun aFailedMetaLookupDoesNotBlankTheResults() {
|
||||
// Meta drives the staleness banner and the filter options; it is secondary.
|
||||
api.market = MarketPageDto(listings = listOf(MarketListingDto(serial = "0x1")), total = 1)
|
||||
|
||||
val vm = MarketViewModel(repo())
|
||||
|
||||
assertTrue(vm.state.value is UiState.Success)
|
||||
}
|
||||
|
||||
@Test fun aVendorLoadsBySerialAndKeepsItForRetry() {
|
||||
api.marketVendor = MarketVendorDto(serial = "0x40001234", shopName = "Darrow's Wares", truncated = true)
|
||||
|
||||
val vm = MarketVendorViewModel(repo())
|
||||
vm.load("0x40001234")
|
||||
|
||||
val vendor = (vm.state.value as UiState.Success).data
|
||||
assertEquals("Darrow's Wares", vendor.shopName)
|
||||
assertTrue(vendor.truncated)
|
||||
|
||||
// Retry re-uses the serial rather than needing it passed again.
|
||||
api.error = httpError(500)
|
||||
vm.retry()
|
||||
assertTrue(vm.state.value is UiState.Error)
|
||||
}
|
||||
|
||||
// ── Atlas ────────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun atlasLoadsCreaturesAndDiscoversTheShardsFacets() {
|
||||
// Nothing may NAME a facet — a shard can add, replace or rename them, so the
|
||||
// filter options come from the shard's own data.
|
||||
api.atlasCreatures = AtlasCreaturePageDto(
|
||||
creatures = listOf(
|
||||
AtlasCreatureDto(slug = "lizardman", facets = mapOf("Felucca" to 30, "Sosaria" to 2)),
|
||||
AtlasCreatureDto(slug = "orc", facets = mapOf("Underdark" to 5)),
|
||||
),
|
||||
total = 2,
|
||||
)
|
||||
|
||||
val vm = AtlasViewModel(repo())
|
||||
|
||||
assertEquals(2, (vm.state.value as UiState.Success).data.creatures.size)
|
||||
assertEquals(listOf("Felucca", "Sosaria", "Underdark"), vm.facets.value)
|
||||
}
|
||||
|
||||
@Test fun aFilteredPageDoesNotNarrowTheFacetOptions() {
|
||||
api.atlasCreatures = AtlasCreaturePageDto(
|
||||
creatures = listOf(AtlasCreatureDto(slug = "a", facets = mapOf("Felucca" to 1, "Trammel" to 1))),
|
||||
)
|
||||
val vm = AtlasViewModel(repo())
|
||||
assertEquals(listOf("Felucca", "Trammel"), vm.facets.value)
|
||||
|
||||
// Filtering to one facet must not leave the picker with only that option.
|
||||
api.atlasCreatures = AtlasCreaturePageDto(
|
||||
creatures = listOf(AtlasCreatureDto(slug = "a", facets = mapOf("Felucca" to 1))),
|
||||
)
|
||||
vm.onFacetChange("Felucca")
|
||||
|
||||
assertEquals(listOf("Felucca", "Trammel"), vm.facets.value)
|
||||
assertEquals("Felucca", api.lastAtlasFacet)
|
||||
}
|
||||
|
||||
@Test fun aGatedAtlasReadsAsUnavailable() {
|
||||
api.error = httpError(404)
|
||||
|
||||
val state = AtlasViewModel(repo()).state.value
|
||||
|
||||
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
@Test fun aCreatureLoadsBySlug() {
|
||||
api.atlasCreature = AtlasCreatureDto(slug = "lizardman", name = "Lizardman", total = 214)
|
||||
|
||||
val vm = AtlasCreatureViewModel(repo())
|
||||
vm.load("lizardman")
|
||||
|
||||
assertEquals(214, (vm.state.value as UiState.Success).data.total)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user