Merge pull request 'test(coverage): raise unit coverage past the 50% gate (phases 0-2)' (#27) from test/coverage-phase-0-1-2 into main
Some checks failed
SonarQube / analysis (push) Has been cancelled

Reviewed-on: #27
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-22 21:11:54 +00:00
30 changed files with 1876 additions and 8 deletions

View File

@@ -0,0 +1,16 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import kotlinx.coroutines.flow.Flow
/**
* The live shard SSE feed as a cold flow of lifecycle + frame events (PLAN.md §6.2,
* §7). Extracted as an interface so consumers (e.g. [com.runicgateway.app.data.repository.ShardRepository])
* depend on the capability, not the OkHttp-backed [ShardStreamClient] — the boards
* can then be unit-tested against a fake stream instead of a real network connection.
*/
interface ShardStream {
fun events(): Flow<ShardStreamEvent>
}

View File

@@ -40,7 +40,7 @@ class ShardStreamClient @Inject constructor(
baseClient: OkHttpClient,
private val baseUrlHolder: BaseUrlHolder,
private val json: Json,
) {
) : ShardStream {
// SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
// so the read timeout must be disabled or the idle stream would be killed.
private val sseClient: OkHttpClient = baseClient.newBuilder()
@@ -56,7 +56,7 @@ class ShardStreamClient @Inject constructor(
* drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
* `{ kind, … }` payload the boards merge in place.
*/
fun events(): Flow<ShardStreamEvent> = channelFlow {
override fun events(): Flow<ShardStreamEvent> = channelFlow {
var backoffMs = INITIAL_BACKOFF_MS
while (isActive) {
val url = baseUrlHolder.current?.resolve(STREAM_PATH)

View File

@@ -3,7 +3,7 @@
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.net.ShardStreamClient
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
@@ -35,7 +35,7 @@ import javax.inject.Singleton
@Singleton
class ShardRepository @Inject constructor(
private val api: PublicApi,
private val stream: ShardStreamClient,
private val stream: ShardStream,
private val json: Json,
) {
// ── Snapshots ────────────────────────────────────────────────────────

View File

@@ -9,6 +9,8 @@ import com.runicgateway.app.BuildConfig
import com.runicgateway.app.core.net.AuthInterceptor
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.net.HostSelectionInterceptor
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamClient
import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
@@ -94,6 +96,12 @@ object NetworkModule {
@Singleton
fun providePublicApi(retrofit: Retrofit): PublicApi = retrofit.create(PublicApi::class.java)
/** Expose the live SSE feed as the [ShardStream] capability so repositories depend
* on the interface (unit-testable against a fake), not the OkHttp-backed client. */
@Provides
@Singleton
fun provideShardStream(client: ShardStreamClient): ShardStream = client
@Provides
@Singleton
fun provideAuthApi(retrofit: Retrofit): AuthApi = retrofit.create(AuthApi::class.java)

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The [ApiResult] helpers: [map] transforms an [ApiResult.Ok] and passes the two
* failure variants through unchanged; [isShardUnavailable] is the 503 "shard down"
* signal the player screens render as offline.
*/
class ApiResultExtrasTest {
@Test fun mapTransformsOkBody() {
val mapped = ApiResult.Ok(listOf(1, 2, 3)).map { it.size }
assertEquals(ApiResult.Ok(3), mapped)
}
@Test fun mapPassesFailuresThroughUnchanged() {
val http: ApiResult<Int> = ApiResult.HttpError(500, "boom")
assertSame(http, http.map { it + 1 })
val cause = RuntimeException("offline")
val network: ApiResult<Int> = ApiResult.NetworkError(cause)
val out = network.map { it + 1 }
assertTrue(out is ApiResult.NetworkError)
assertSame(cause, (out as ApiResult.NetworkError).cause)
}
@Test fun isShardUnavailableOnlyForHttp503() {
assertTrue(ApiResult.HttpError(503).isShardUnavailable())
assertFalse(ApiResult.HttpError(500).isShardUnavailable())
assertFalse(ApiResult.Ok(Unit).isShardUnavailable())
assertFalse(ApiResult.NetworkError(RuntimeException()).isShardUnavailable())
}
}

View File

@@ -0,0 +1,127 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the staff-operations DTOs (`/admin/…`, PLAN.md §6.4).
* Covers the snake_case `@SerialName` mappings, the `AdminPostDto.isPublished`
* tinyint bridge, nested dashboard shapes, and the request bodies the app encodes.
*/
class AdminDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun dashboardDecodesNestedCountsAndActivity() {
val dto = json.decodeFromString<AdminDashboardDto>(
"""{
"site_mode":"maintenance",
"last_change":{"at":"2026-07-20T10:00:00Z","by":"admin"},
"counts":{"posts":{"news":4,"newsletter":1},"users":37},
"recent_activity":[
{"id":9,"username":"mod","action":"post.publish",
"detail":{"postId":12},"created_at":"2026-07-22T09:00:00Z"}
]
}""",
)
assertEquals("maintenance", dto.siteMode)
assertEquals("admin", dto.lastChange.by)
assertEquals(4, dto.counts.posts["news"])
assertEquals(37, dto.counts.users)
assertEquals(1, dto.recentActivity.size)
assertEquals("post.publish", dto.recentActivity[0].action)
// `detail` is provider-shaped JSON kept as a raw element.
assertEquals(12, dto.recentActivity[0].detail!!.jsonObject["postId"]!!.jsonPrimitive.int)
}
@Test fun dashboardDefaultsWhenKeysAbsent() {
val dto = json.decodeFromString<AdminDashboardDto>("{}")
assertEquals("live", dto.siteMode)
assertTrue(dto.counts.posts.isEmpty())
assertTrue(dto.recentActivity.isEmpty())
}
@Test fun adminPostBridgesPublishedTinyintToBoolean() {
val published = json.decodeFromString<AdminPostDto>(
"""{"id":1,"category":"news","title":"Hi","published":1,"published_at":"2026-07-21T00:00:00Z"}""",
)
assertTrue(published.isPublished)
assertEquals("2026-07-21T00:00:00Z", published.publishedAt)
val draft = json.decodeFromString<AdminPostDto>("""{"id":2,"title":"Draft","published":0}""")
assertFalse(draft.isPublished)
}
@Test fun adminWikiCategoryAndTagDecodeCounts() {
val cat = json.decodeFromString<AdminWikiCategoryDto>(
"""{"id":3,"slug":"lore","title":"Lore","sort_order":2,"page_count":5,"published_count":4}""",
)
assertEquals(2, cat.sortOrder)
assertEquals(5, cat.pageCount)
assertEquals(4, cat.publishedCount)
val tag = json.decodeFromString<AdminWikiTagDto>("""{"id":8,"slug":"pvp","label":"PvP","published_count":11}""")
assertEquals("PvP", tag.label)
assertEquals(11, tag.publishedCount)
}
@Test fun supportPageDecodesSenderActor() {
val dto = json.decodeFromString<SupportPageDto>(
"""{"pageId":"0x1A2B","type":"other","message":"stuck",
"handled":false,"sender":{"name":"Gwen","account":"gwen01"}}""",
)
assertEquals("0x1A2B", dto.pageId)
assertEquals("Gwen", dto.sender?.name)
assertEquals("gwen01", dto.sender?.account)
assertEquals(false, dto.handled)
}
@Test fun supportPageToleratesMissingSender() {
val dto = json.decodeFromString<SupportPageDto>("""{"pageId":"0x01"}""")
assertNull(dto.sender)
assertNull(dto.type)
}
@Test fun siteModeStateDecodesAudit() {
val dto = json.decodeFromString<SiteModeStateDto>(
"""{"site_mode":"maintenance","changed_at":"2026-07-22T08:00:00Z","changed_by":"admin"}""",
)
assertEquals("maintenance", dto.siteMode)
assertEquals("admin", dto.changedBy)
}
@Test fun requestBodiesEncodeWithSnakeCaseKeys() {
assertTrue(json.encodeToString(SiteModeRequest("maintenance")).contains("\"mode\":\"maintenance\""))
assertTrue(json.encodeToString(PublishRequest(true)).contains("\"published\":true"))
assertTrue(json.encodeToString(UnbanRequest("gwen01")).contains("\"account\":\"gwen01\""))
assertTrue(json.encodeToString(BroadcastRequest("hello", hue = 33)).contains("\"hue\":33"))
assertTrue(json.encodeToString(PageRespondRequest("done", close = true)).contains("\"close\":true"))
assertTrue(json.encodeToString(WikiCategoryRequest(slug = "lore", title = "Lore", sortOrder = 1))
.contains("\"sort_order\":1"))
val post = json.encodeToString(PostCreateRequest(category = "news", title = "T", imageUrl = "/img.png"))
assertTrue(post.contains("\"image_url\":\"/img.png\""))
assertTrue(post.contains("\"category\":\"news\""))
val ban = json.encodeToString(BanRequest(account = "x", durationSec = 3600, reason = "afk"))
assertTrue(ban.contains("\"durationSec\":3600"))
val kick = json.encodeToString(KickRequest(serial = "0xFF"))
assertTrue(kick.contains("\"serial\":\"0xFF\""))
}
}

View File

@@ -0,0 +1,68 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Encode/decode tests for the mobile bearer-auth request bodies (`/auth/mobile/…`)
* and the token pair — the snake_case `device_name`, the omit-nulls behaviour, and
* the trusted-device outcome fields on the login response.
*/
class AuthRequestDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun loginRequestEncodesSnakeCaseDeviceNameAndOmitsNulls() {
val body = json.encodeToString(
MobileLoginRequest(username = "gwen", password = "pw", trustDevice = true, device_name = "Pixel 8"),
)
assertTrue(body.contains("\"username\":\"gwen\""))
assertTrue(body.contains("\"device_name\":\"Pixel 8\""))
assertTrue(body.contains("\"trustDevice\":true"))
assertFalse(body.contains("\"code\"")) // null omitted (explicitNulls = false)
}
@Test fun loginRequestCarriesSecondFactorOnRetry() {
val withCode = json.encodeToString(MobileLoginRequest("u", "p", code = "123456"))
assertTrue(withCode.contains("\"code\":\"123456\""))
val withRecovery = json.encodeToString(MobileLoginRequest("u", "p", recoveryCode = "aaaa-1111"))
assertTrue(withRecovery.contains("\"recoveryCode\":\"aaaa-1111\""))
}
@Test fun refreshAndLogoutBodiesEncode() {
assertTrue(json.encodeToString(MobileRefreshRequest("rt")).contains("\"refreshToken\":\"rt\""))
assertTrue(json.encodeToString(MobileLogoutRequest(all = true)).contains("\"all\":true"))
}
@Test fun tokenResponseDecodesTrustOutcome() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","expiresIn":"15m",
"user":{"id":1,"username":"gwen","role":"player"},
"trustToken":"opaque"}""",
)
assertEquals("a", dto.accessToken)
assertEquals("opaque", dto.trustToken)
assertFalse(dto.trustLimitReached)
assertEquals("gwen", dto.user.username)
}
@Test fun tokenResponseDecodesTrustLimitReached() {
val dto = json.decodeFromString<MobileTokenResponse>(
"""{"accessToken":"a","refreshToken":"r","user":{"id":1,"username":"g","role":"player"},
"trustLimitReached":true,"devices":[{"id":1,"platform":"web","deviceName":"FF"}]}""",
)
assertTrue(dto.trustLimitReached)
assertEquals(1, dto.devices.size)
}
}

View File

@@ -0,0 +1,82 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the news post + CMS page + contact DTOs (`/public/posts…`,
* `/public/pages/:slug`, `/public/contact`). One [PostDto] shape serves both the
* list (no body) and detail (with body); a [PageDto] keeps block props as raw JSON.
*/
class ContentDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun postDetailDecodesBodyAndImage() {
val dto = json.decodeFromString<PostDto>(
"""{"id":10,"category":"news","title":"Update","slug":"update",
"excerpt":"e","body":"<p>full</p>","image_url":"/i.png",
"published_at":"2026-07-21T00:00:00Z","created_at":"2026-07-20T00:00:00Z"}""",
)
assertEquals(10L, dto.id)
assertEquals("<p>full</p>", dto.body)
assertEquals("/i.png", dto.imageUrl)
assertEquals("2026-07-21T00:00:00Z", dto.publishedAt)
}
@Test fun postListRowToleratesMissingBody() {
val dto = json.decodeFromString<PostDto>("""{"id":11,"category":"newsletter","title":"N"}""")
assertNull(dto.body)
assertNull(dto.imageUrl)
}
@Test fun pageDecodesBlocksWithRawProps() {
val dto = json.decodeFromString<PageDto>(
"""{
"id":3,"slug":"about","title":"About","status":"published",
"blocks":[
{"type":"heading","props":{"text":"Welcome","level":1},"visible":true},
{"type":"divider","props":{}}
],
"publishedAt":"2026-07-01T00:00:00Z"
}""",
)
assertEquals("about", dto.slug)
assertEquals(2, dto.blocks.size)
assertEquals("heading", dto.blocks[0].type)
// props stay a raw JSON object the renderer reads by key.
assertEquals("Welcome", dto.blocks[0].props["text"]!!.jsonPrimitive.content)
assertTrue(dto.blocks[1].visible) // default true when absent
}
@Test fun contactResponseSentAndFallbackVariants() {
val sent = json.decodeFromString<ContactResponse>("""{"sent":true}""")
assertTrue(sent.sent)
assertNull(sent.fallback)
val fallback = json.decodeFromString<ContactResponse>(
"""{"sent":false,"fallback":"mailto","email":"a@b.c"}""",
)
assertEquals("mailto", fallback.fallback)
assertEquals("a@b.c", fallback.email)
}
@Test fun contactRequestEncodesAllFields() {
val body = json.encodeToString(ContactRequest(name = "Gwen", email = "g@x.c", message = "hi"))
assertTrue(body.contains("\"name\":\"Gwen\""))
assertTrue(body.contains("\"email\":\"g@x.c\""))
assertTrue(body.contains("\"message\":\"hi\""))
}
}

View File

@@ -0,0 +1,115 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the player self-service game-data DTOs
* (`/player/shard/…`): account linking, roster, the full character sheet, vendors,
* sales, and own-houses. Only the fields the text-only v1 renders are asserted.
*/
class PlayerGameDataDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun linkRequestAndResultRoundTrip() {
assertTrue(json.encodeToString(ShardLinkRequest("ABC123")).contains("\"code\":\"ABC123\""))
val result = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"acct1"}""")
assertTrue(result.linked)
assertEquals("acct1", result.account)
assertTrue(json.encodeToString(CreateGameAccountRequest("acct1", "pw")).contains("\"account\":\"acct1\""))
}
@Test fun linkedAccountDecodes() {
val dto = json.decodeFromString<ShardLinkDto>(
"""{"account":"acct1","userId":42,"charName":"Gwen","linkedAt":"2026-07-20T00:00:00Z"}""",
)
assertEquals("acct1", dto.account)
assertEquals(42L, dto.userId)
}
@Test fun rosterDecodesCharacters() {
val dto = json.decodeFromString<RosterDto>(
"""{"acct":"acct1","chars":[
{"slot":0,"serial":"0x24C","name":"Gwen","body":401,"online":true},
{"slot":1,"serial":"0x24D","name":"Alt","online":false}]}""",
)
assertEquals(2, dto.chars.size)
assertTrue(dto.chars[0].online)
assertEquals("0x24C", dto.chars[0].serial)
}
@Test fun charSheetDecodesStatsSkillsEquipmentTitlesGuild() {
val dto = json.decodeFromString<CharProfileDto>(
"""{
"serial":"0x24C","name":"Gwen","title":"the Brave","online":true,"acct":"acct1",
"stats":{"str":100,"dex":90,"int":80,"hits":95,"hitsMax":100,
"resist":{"phys":70,"fire":50,"cold":45,"pois":40,"energy":35}},
"skills":[{"n":"Swords","base":100.0,"value":110.0,"cap":120.0}],
"equipment":[{"serial":"0x9","layer":"OneHanded","itemId":5044,"hue":0}],
"titles":{"selected":0,"reward":["1049643"],"fameKarma":"Glorious"},
"guild":{"name":"Knights","abbr":"KoT"},
"governorOf":["Britain"]
}""",
)
assertEquals("Gwen", dto.name)
assertEquals(100, dto.stats!!.str)
assertEquals(70, dto.stats!!.resist!!.phys)
assertEquals(110.0, dto.skills.first().value!!, 0.0)
assertEquals("OneHanded", dto.equipment.first().layer)
assertEquals("Glorious", dto.titles!!.fameKarma)
assertEquals("Knights", dto.guild!!.name)
assertEquals(listOf("Britain"), dto.governorOf)
}
@Test fun charSheetToleratesMinimalPayload() {
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x1","name":"Bare"}""")
assertEquals(null, dto.stats)
assertTrue(dto.skills.isEmpty())
assertTrue(dto.equipment.isEmpty())
assertFalse(dto.online)
}
@Test fun vendorSnapshotAndListingsDecode() {
val dto = json.decodeFromString<VendorSnapshotDto>(
"""{"acct":"acct1","vendors":[
{"serial":"0x9","shopName":"Wares","holdGold":5000,"map":"Felucca","x":1,"y":2,
"listings":[{"serial":"0xA","itemId":3862,"amount":5,"price":250,"forSale":true}]}]}""",
)
val vendor = dto.vendors.first()
assertEquals("Wares", vendor.shopName)
assertEquals(5000L, vendor.holdGold)
val listing = vendor.listings.first()
assertEquals(250L, listing.price)
assertTrue(listing.forSale)
}
@Test fun vendorSaleDecodes() {
val dto = json.decodeFromString<VendorSaleDto>(
"""{"t":1700000000000,"itemType":"katana","amount":1,"price":1000,"commission":50,"ownerAcct":"acct1"}""",
)
assertEquals(1000L, dto.price)
assertEquals(50, dto.commission)
}
@Test fun playerHouseDecodesDecayFields() {
val dto = json.decodeFromString<PlayerHouseDto>(
"""{"serial":"0x40001","stage":"LikeNew","region":"Britain","name":"Keep",
"isIdoc":false,"builtOn":"2026-01-01T00:00:00Z","lastRefreshed":"2026-07-22T00:00:00Z"}""",
)
assertEquals("LikeNew", dto.stage)
assertEquals("Keep", dto.name)
assertFalse(dto.isIdoc)
}
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the public site/identity DTOs (`/public/status`,
* `/public/settings`). Covers the `StatusDto.isMaintenance` derivation, nested
* branding/registration/push blocks, and the additive-field tolerance.
*/
class PublicDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun statusDecodesVersionAndMaintenanceFlag() {
val dto = json.decodeFromString<StatusDto>(
"""{"mode":"MAINTENANCE","status_message":"back soon",
"version":{"service":"web","api":"v1","server":"1.2.3"}}""",
)
assertTrue(dto.isMaintenance) // case-insensitive
assertEquals("back soon", dto.statusMessage)
assertEquals("1.2.3", dto.version.server)
}
@Test fun liveStatusIsNotMaintenance() {
assertFalse(json.decodeFromString<StatusDto>("""{"mode":"live"}""").isMaintenance)
}
@Test fun statusDefaultsWhenEmpty() {
val dto = json.decodeFromString<StatusDto>("{}")
assertEquals("live", dto.mode)
assertFalse(dto.isMaintenance)
assertEquals("", dto.version.api)
}
@Test fun settingsDecodesBrandRegistrationAndPush() {
val dto = json.decodeFromString<SettingsDto>(
"""{
"site_title":"UOMysticmoon","status_message":"welcome",
"registration":{"password":true,"sso":false},
"gameAccountSignup":true,
"brand":{"name":"UOMysticmoon","shortName":"UOM","accent":"#7f99bd",
"logo":"/logo.png","hero":"/hero.png","contactEmail":"a@b.c","url":"https://x"},
"push":{"ntfyUrl":"https://ntfy.example.com"}
}""",
)
assertEquals("UOMysticmoon", dto.siteTitle)
assertTrue(dto.registration.password)
assertFalse(dto.registration.sso)
assertTrue(dto.gameAccountSignup)
assertEquals("#7f99bd", dto.brand.accent)
assertEquals("/logo.png", dto.brand.logo)
assertEquals("https://ntfy.example.com", dto.push.ntfyUrl)
}
@Test fun settingsDefaultsOnOlderBackend() {
// A backend that predates push/branding: nested blocks fall back to defaults.
val dto = json.decodeFromString<SettingsDto>("""{"site_title":"Bare"}""")
assertFalse(dto.registration.password)
assertEquals("", dto.brand.name)
assertEquals(null, dto.push.ntfyUrl)
}
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the public shard board DTOs (`/public/shard/…`), covering the
* computed helpers ([ActorDto.label], [ShardStatusDto.isOnline]) and the
* permissive board payloads (champ/guild/governor/house/presence).
*/
class ShardBoardDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun actorLabelPrefersNameThenAcctThenFallback() {
assertEquals("Gwen", json.decodeFromString<ActorDto>("""{"name":"Gwen","acct":"g01"}""").label)
assertEquals("g01", json.decodeFromString<ActorDto>("""{"acct":"g01"}""").label)
assertEquals("Someone", json.decodeFromString<ActorDto>("{}").label)
}
@Test fun shardStatusIsOnlineOnlyWhenEnabledAndPluginConnected() {
val online = json.decodeFromString<ShardStatusDto>(
"""{"enabled":true,"pluginConnected":true,"onlineCount":42,
"economy":{"accounts":10,"gold":123456.0,"t":1000}}""",
)
assertTrue(online.isOnline)
assertEquals(42, online.onlineCount)
assertEquals(123456.0, online.economy!!.gold!!, 0.0)
assertFalse(json.decodeFromString<ShardStatusDto>("""{"enabled":true,"pluginConnected":false}""").isOnline)
assertFalse(json.decodeFromString<ShardStatusDto>("{}").isOnline)
}
@Test fun champDecodesBossAndProgressFields() {
val dto = json.decodeFromString<ChampDto>(
"""{"serial":"0x1","category":"champion","name":"Rikktor","active":true,
"bossUp":true,"boss":"Rikktor","level":3,"maxLevel":16,"kills":10,"maxKills":100,
"hits":5000,"hitsMax":9000,"map":"Felucca","x":1,"y":2,"z":0}""",
)
assertTrue(dto.active)
assertTrue(dto.bossUp)
assertEquals(16, dto.maxLevel)
assertEquals(5000L, dto.hits)
}
@Test fun guildDecodesLeaderActor() {
val dto = json.decodeFromString<GuildDto>(
"""{"id":7,"name":"Knights","abbr":"KoT","members":12,"online":3,
"leader":{"name":"Arthur","webId":"9931"}}""",
)
assertEquals("Knights", dto.name)
assertEquals("Arthur", dto.leader!!.label)
assertEquals("9931", dto.leader!!.webId)
}
@Test fun governorAndTermDecode() {
val gov = json.decodeFromString<GovernorDto>(
"""{"city":"Britain","governor":{"name":"Dawn"},"electionPhase":"campaign"}""",
)
assertEquals("Britain", gov.city)
assertEquals("Dawn", gov.governor!!.label)
val term = json.decodeFromString<GovernorTermDto>(
"""{"city":"Britain","governor":{"name":"Dawn"},"startedAt":1000,"endedAt":2000,"votes":50}""",
)
assertEquals(50, term.votes)
assertEquals(2000L, term.endedAt)
}
@Test fun houseAndPresenceAndStaffDecode() {
val house = json.decodeFromString<HouseDto>(
"""{"serial":"0x40","name":"Tower","region":"Britain","isIdoc":true,"x":5,"y":6}""",
)
assertTrue(house.isIdoc)
assertEquals("Tower", house.name)
val presence = json.decodeFromString<PresenceDto>(
"""{"count":30,"byFacet":{"Felucca":10,"Trammel":20},"byRegion":{"Britain":5}}""",
)
assertEquals(30, presence.count)
assertEquals(10, presence.byFacet["Felucca"])
val staff = json.decodeFromString<OnlineStaffDto>("""{"serial":"0x2","name":"GM Bob","map":"Felucca","x":1,"y":2,"z":0}""")
assertEquals("GM Bob", staff.name)
}
@Test fun feedEventDecodesPayloadObject() {
val ev = json.decodeFromString<FeedEventDto>(
"""{"id":9,"kind":"champ.spawn","t":1234,"payload":{"name":"Rikktor"},"createdAt":"2026-07-22T00:00:00Z"}""",
)
assertEquals("champ.spawn", ev.kind)
assertTrue(ev.payload!!.containsKey("name"))
}
}

View File

@@ -0,0 +1,46 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode/encode tests for the Mobile SSO bridge DTOs (PLAN.md §4.2). Provider
* discovery is public (never secrets); the exchange body uses snake_case
* `code_verifier` to match the backend.
*/
class SsoDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun providerDecodesWithOptionalFields() {
val dto = json.decodeFromString<SsoProviderDto>(
"""{"id":"discord","name":"Discord","icon":"discord","loginUrl":"/auth/discord","priority":2}""",
)
assertEquals("discord", dto.id)
assertEquals("Discord", dto.name)
assertEquals(2, dto.priority)
}
@Test fun providerToleratesMissingOptionals() {
val dto = json.decodeFromString<SsoProviderDto>("""{"id":"oidc","name":"Corp SSO"}""")
assertNull(dto.icon)
assertNull(dto.priority)
}
@Test fun exchangeRequestEncodesSnakeCaseVerifier() {
val body = json.encodeToString(MobileSsoExchangeRequest(code = "abc123", codeVerifier = "v-e-r-i-f-i-e-r"))
assertTrue(body.contains("\"code\":\"abc123\""))
assertTrue(body.contains("\"code_verifier\":\"v-e-r-i-f-i-e-r\""))
}
}

View File

@@ -0,0 +1,66 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the wiki DTOs (`/public/wiki*`). Summary rows omit the body;
* the detail page carries tags, backlinks, and unresolved ("red") link targets.
*/
class WikiDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun summaryRowDecodesWithCategory() {
val dto = json.decodeFromString<WikiSummaryDto>(
"""{"id":5,"slug":"pvp","title":"PvP","excerpt":"combat",
"category_slug":"systems","category_title":"Systems","updated_at":"2026-07-20T00:00:00Z"}""",
)
assertEquals(5L, dto.id)
assertEquals("systems", dto.categorySlug)
assertEquals("Systems", dto.categoryTitle)
assertEquals("combat", dto.excerpt)
}
@Test fun pageDecodesTagsBacklinksAndMissingLinks() {
val dto = json.decodeFromString<WikiPageDto>(
"""{
"id":9,"slug":"housing","title":"Housing","body":"<p>text</p>",
"category_slug":"systems","category_title":"Systems",
"tags":[{"slug":"idoc","label":"IDOC"}],
"backlinks":[{"slug":"pvp","title":"PvP"}],
"missing_links":["nonexistent-page"]
}""",
)
assertEquals("<p>text</p>", dto.body)
assertEquals(1, dto.tags.size)
assertEquals("IDOC", dto.tags[0].label)
assertEquals("pvp", dto.backlinks[0].slug)
assertEquals(listOf("nonexistent-page"), dto.missingLinks)
}
@Test fun pageDefaultsCollectionsWhenAbsent() {
val dto = json.decodeFromString<WikiPageDto>("""{"id":1,"slug":"x","title":"X"}""")
assertTrue(dto.tags.isEmpty())
assertTrue(dto.backlinks.isEmpty())
assertTrue(dto.missingLinks.isEmpty())
}
@Test fun categoryAndTagDecodePublishedCounts() {
val cat = json.decodeFromString<WikiCategoryDto>(
"""{"id":2,"slug":"systems","title":"Systems","description":"d","published_count":12}""",
)
assertEquals(12L, cat.publishedCount)
val tag = json.decodeFromString<WikiTagDto>("""{"id":4,"slug":"idoc","label":"IDOC","published_count":3}""")
assertEquals(3L, tag.publishedCount)
}
}

View File

@@ -0,0 +1,88 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.AdminApi
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.api.dto.BanRequest
import com.runicgateway.app.data.api.dto.BroadcastRequest
import com.runicgateway.app.data.api.dto.KickRequest
import com.runicgateway.app.data.api.dto.PageRespondRequest
import com.runicgateway.app.data.api.dto.PostCreateRequest
import com.runicgateway.app.data.api.dto.PublishRequest
import com.runicgateway.app.data.api.dto.SiteModeRequest
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.dto.UnbanRequest
import com.runicgateway.app.data.api.dto.WikiCategoryRequest
import com.runicgateway.app.util.okUnit
import retrofit2.Response
/**
* A configurable fake of [AdminApi] for the staff-ops repository/ViewModel tests.
* Read endpoints return their `var`; write endpoints returning `Response<Unit>`
* return [unitResponse] (default 200) so a test can drive the 200 / 403 / 503 copy
* branches. Set [error] to throw from every call (network / decode failure paths).
*/
class FakeAdminApi : AdminApi {
var error: Throwable? = null
var dashboard: AdminDashboardDto = AdminDashboardDto()
var siteMode: SiteModeStateDto = SiteModeStateDto()
var posts: List<AdminPostDto> = emptyList()
var createdPost: AdminPostDto = AdminPostDto(id = 0)
var publishedPost: AdminPostDto = AdminPostDto(id = 0)
var wikiCategories: List<AdminWikiCategoryDto> = emptyList()
var createdCategory: AdminWikiCategoryDto = AdminWikiCategoryDto(id = 0)
var wikiTags: List<AdminWikiTagDto> = emptyList()
var supportPages: List<SupportPageDto> = emptyList()
/** Response returned by the bodyless write endpoints (kick/ban/delete/respond/…). */
var unitResponse: Response<Unit> = okUnit()
/** Bodies seen by write calls, so a test can assert what was sent. */
var lastPostCreate: PostCreateRequest? = null
var lastBan: BanRequest? = null
var lastRespond: Pair<String, PageRespondRequest>? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun dashboard(): AdminDashboardDto = reply(dashboard)
override suspend fun setSiteMode(body: SiteModeRequest): SiteModeStateDto = reply(siteMode)
override suspend fun posts(): List<AdminPostDto> = reply(posts)
override suspend fun createPost(body: PostCreateRequest): AdminPostDto {
lastPostCreate = body
return reply(createdPost)
}
override suspend fun publishPost(id: Long, body: PublishRequest): AdminPostDto = reply(publishedPost)
override suspend fun deletePost(id: Long): Response<Unit> = reply(unitResponse)
override suspend fun wikiCategories(): List<AdminWikiCategoryDto> = reply(wikiCategories)
override suspend fun createWikiCategory(body: WikiCategoryRequest): AdminWikiCategoryDto = reply(createdCategory)
override suspend fun deleteWikiCategory(id: Long): Response<Unit> = reply(unitResponse)
override suspend fun wikiTags(): List<AdminWikiTagDto> = reply(wikiTags)
override suspend fun kick(body: KickRequest): Response<Unit> = reply(unitResponse)
override suspend fun ban(body: BanRequest): Response<Unit> {
lastBan = body
return reply(unitResponse)
}
override suspend fun unban(body: UnbanRequest): Response<Unit> = reply(unitResponse)
override suspend fun broadcast(body: BroadcastRequest): Response<Unit> = reply(unitResponse)
override suspend fun supportPages(): List<SupportPageDto> = reply(supportPages)
override suspend fun respondPage(id: String, body: PageRespondRequest): Response<Unit> {
lastRespond = id to body
return reply(unitResponse)
}
override suspend fun closePage(id: String): Response<Unit> = reply(unitResponse)
}

View File

@@ -0,0 +1,47 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PlayerShardApi
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.CreateGameAccountRequest
import com.runicgateway.app.data.api.dto.PlayerHouseDto
import com.runicgateway.app.data.api.dto.RosterDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.ShardLinkRequest
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
import com.runicgateway.app.data.api.dto.VendorSaleDto
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
/**
* A configurable fake of [PlayerShardApi] for the player self-service repository /
* ViewModel tests. Set the relevant `var`; set [error] to throw from every call
* (drives the `503 shard offline` / `403 not-linked` / network paths).
*/
class FakePlayerShardApi : PlayerShardApi {
var error: Throwable? = null
var linkResult: ShardLinkResultDto = ShardLinkResultDto()
var accounts: List<ShardLinkDto> = emptyList()
var roster: RosterDto = RosterDto()
var char: CharProfileDto = CharProfileDto()
var vendors: VendorSnapshotDto = VendorSnapshotDto()
var sales: List<VendorSaleDto> = emptyList()
var houses: List<PlayerHouseDto> = emptyList()
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun link(body: ShardLinkRequest): ShardLinkResultDto = reply(linkResult)
override suspend fun createAccount(body: CreateGameAccountRequest): ShardLinkResultDto = reply(linkResult)
override suspend fun accounts(): List<ShardLinkDto> = reply(accounts)
override suspend fun roster(account: String): RosterDto = reply(roster)
override suspend fun char(serial: String): CharProfileDto = reply(char)
override suspend fun vendors(account: String): VendorSnapshotDto = reply(vendors)
override suspend fun sales(): List<VendorSaleDto> = reply(sales)
override suspend fun houses(): List<PlayerHouseDto> = reply(houses)
}

View File

@@ -0,0 +1,98 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
import com.runicgateway.app.data.api.dto.EconomySampleDto
import com.runicgateway.app.data.api.dto.FeedEventDto
import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
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.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.dto.WikiTagDto
/**
* A configurable fake of [PublicApi] for repository/ViewModel tests. Set the
* relevant `var` to the body a call should return; set [error] to make every call
* throw (drives the `ApiResult.HttpError` / `NetworkError` paths). Defaults are
* empty/neutral so a call an assertion doesn't care about never crashes.
*/
class FakePublicApi : PublicApi {
/** When non-null, every call throws this (use `httpError(code)` or an IOException). */
var error: Throwable? = null
var status: StatusDto = StatusDto()
var settings: SettingsDto = SettingsDto()
var posts: List<PostDto> = emptyList()
var post: PostDto = PostDto(id = 0)
var page: PageDto = PageDto(id = 0)
var wikiPages: List<WikiSummaryDto> = emptyList()
var wikiCategories: List<WikiCategoryDto> = emptyList()
var wikiTags: List<WikiTagDto> = emptyList()
var wikiPage: WikiPageDto = WikiPageDto(id = 0)
var contactResponse: ContactResponse = ContactResponse(sent = true)
var shardStatus: ShardStatusDto = ShardStatusDto()
var shardFeed: List<FeedEventDto> = emptyList()
var shardEconomy: List<EconomySampleDto> = emptyList()
var shardOnline: List<OnlineStaffDto> = emptyList()
var shardPresence: PresenceDto = PresenceDto()
var champs: List<ChampDto> = emptyList()
var guilds: List<GuildDto> = emptyList()
var governors: List<GovernorDto> = emptyList()
var governorHistory: List<GovernorTermDto> = emptyList()
var houses: List<HouseDto> = emptyList()
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
var lastContact: ContactRequest? = null
private fun <T> reply(value: T): T {
error?.let { throw it }
return value
}
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
override suspend fun getStatus(): StatusDto = reply(status)
override suspend fun getSettings(): SettingsDto = reply(settings)
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
override suspend fun getPage(slug: String): PageDto = reply(page)
override suspend fun getWikiPages(query: String?, category: String?, tag: String?): List<WikiSummaryDto> =
reply(wikiPages)
override suspend fun getWikiCategories(): List<WikiCategoryDto> = reply(wikiCategories)
override suspend fun getWikiTags(): List<WikiTagDto> = reply(wikiTags)
override suspend fun getWikiPage(slug: String): WikiPageDto = reply(wikiPage)
override suspend fun postContact(body: ContactRequest): ContactResponse {
lastContact = body
return reply(contactResponse)
}
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)
override suspend fun getShardOnline(): List<OnlineStaffDto> = reply(shardOnline)
override suspend fun getShardPresence(): PresenceDto = reply(shardPresence)
override suspend fun getShardChamps(): List<ChampDto> = reply(champs)
override suspend fun getShardGuilds(): List<GuildDto> = reply(guilds)
override suspend fun getShardGovernors(): List<GovernorDto> = reply(governors)
override suspend fun getShardGovernorHistory(city: String, limit: Int?): List<GovernorTermDto> =
reply(governorHistory)
override suspend fun getShardHouses(): List<HouseDto> = reply(houses)
}

View File

@@ -0,0 +1,19 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.fake
import com.runicgateway.app.core.net.ShardStream
import com.runicgateway.app.core.net.ShardStreamEvent
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
/**
* A finite fake of the live [ShardStream] for board-ViewModel tests: it emits the
* given [events] once and completes, so the ViewModel's `collectLive()` finishes
* immediately (no perpetual reconnect loop) and any live-frame handling it triggers
* is exercised deterministically.
*/
class FakeShardStream(private val events: List<ShardStreamEvent> = emptyList()) : ShardStream {
override fun events(): Flow<ShardStreamEvent> = flowOf(*events.toTypedArray())
}

View File

@@ -0,0 +1,120 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiPageDto
import com.runicgateway.app.data.api.dto.WikiSummaryDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.WikiRepository
import com.runicgateway.app.ui.home.HomeViewModel
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.news.NewsViewModel
import com.runicgateway.app.ui.news.PostViewModel
import com.runicgateway.app.ui.page.PageViewModel
import com.runicgateway.app.ui.wiki.WikiPageViewModel
import com.runicgateway.app.ui.wiki.WikiViewModel
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** ViewModels over the public content APIs (news, CMS pages, wiki, home status). */
class ContentViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private val content = ContentRepository(api)
private val wiki = WikiRepository(api)
private val settings = SettingsRepository(api)
// ── News hub ──────────────────────────────────────────────────────────
@Test fun newsLoadsSelectedCategory() {
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
val vm = NewsViewModel(content)
assertTrue(vm.state.value is UiState.Success)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsSelectCategoryReloads() {
val vm = NewsViewModel(content)
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsServerErrorIsUiError() {
api.error = httpError(500)
assertTrue(NewsViewModel(content).state.value is UiState.Error)
}
// ── Post detail (SavedStateHandle args) ─────────────────────────────────
@Test fun postDetailLoadsForKnownCategory() {
api.post = PostDto(id = 7, category = "news", title = "Update", body = "<p>x</p>")
val handle = SavedStateHandle(
mapOf(Routes.Args.CATEGORY to "news", Routes.Args.ID_OR_SLUG to "update"),
)
val vm = PostViewModel(content, handle)
assertEquals("Update", (vm.state.value as UiState.Success).data.title)
}
@Test fun postDetailUnknownCategoryIsNotFoundWithoutApiCall() {
val handle = SavedStateHandle(
mapOf(Routes.Args.CATEGORY to "bogus", Routes.Args.ID_OR_SLUG to "x"),
)
val state = PostViewModel(content, handle).state.value
assertTrue(state is UiState.Error)
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
}
// ── CMS page ────────────────────────────────────────────────────────────
@Test fun pageLoadsBySlug() {
api.page = PageDto(id = 3, slug = "about", title = "About")
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "about")))
assertEquals("About", (vm.state.value as UiState.Success).data.title)
}
@Test fun pageNotFoundIsUiError() {
api.error = httpError(404)
val vm = PageViewModel(content, SavedStateHandle(mapOf(Routes.Args.SLUG to "missing")))
assertEquals(ErrorKind.NOT_FOUND, (vm.state.value as UiState.Error).kind)
}
// ── Wiki index + detail ─────────────────────────────────────────────────
@Test fun wikiIndexLoadsAndTracksQuery() {
api.wikiPages = listOf(WikiSummaryDto(id = 1, slug = "pvp", title = "PvP"))
val vm = WikiViewModel(wiki)
assertTrue(vm.state.value is UiState.Success)
vm.onQueryChange("housing")
assertEquals("housing", vm.query.value)
}
@Test fun wikiPageLoadsBySlug() {
api.wikiPage = WikiPageDto(id = 9, slug = "housing", title = "Housing", body = "b")
val vm = WikiPageViewModel(wiki, SavedStateHandle(mapOf(Routes.Args.SLUG to "housing")))
assertEquals("Housing", (vm.state.value as UiState.Success).data.title)
}
// ── Home status ─────────────────────────────────────────────────────────
@Test fun homeLoadsStatus() {
api.status = StatusDto(mode = "maintenance")
val vm = HomeViewModel(settings)
assertTrue((vm.state.value as UiState.Success).data.isMaintenance)
}
@Test fun homeNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
val state = HomeViewModel(settings).state.value
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
}
}

View File

@@ -0,0 +1,78 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AdminPostDto
import com.runicgateway.app.data.api.dto.AdminWikiCategoryDto
import com.runicgateway.app.data.api.dto.AdminWikiTagDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminContentViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminContentViewModel(AdminRepository(api))
@Test fun loadsPostsAndWikiOnInit() {
api.posts = listOf(AdminPostDto(id = 1, title = "A", published = 1))
api.wikiCategories = listOf(AdminWikiCategoryDto(id = 2, slug = "lore", title = "Lore"))
api.wikiTags = listOf(AdminWikiTagDto(id = 3, slug = "pvp", label = "PvP"))
val vm = viewModel()
assertTrue(vm.state.value.posts is UiState.Success)
assertEquals(1, (vm.state.value.posts as UiState.Success).data.size)
assertEquals(1, vm.state.value.tags.size)
}
@Test fun createPostRejectsBlankTitleWithoutCallingApi() {
val vm = viewModel()
vm.createPost(category = "news", title = " ", excerpt = "", body = "", published = false)
assertFalse(vm.state.value.feedback!!.ok)
assertEquals(R.string.admin_content_title_required, vm.state.value.feedback!!.messageRes)
assertEquals(null, api.lastPostCreate) // never reached the API
}
@Test fun createPostTrimsAndNullsBlanksThenReloads() {
val vm = viewModel()
vm.createPost(category = "news", title = " Hello ", excerpt = "", body = "b", published = true)
val sent = api.lastPostCreate!!
assertEquals("Hello", sent.title)
assertEquals(null, sent.excerpt) // blank -> null
assertEquals("b", sent.body)
assertTrue(vm.state.value.feedback!!.ok)
assertFalse(vm.state.value.busy)
}
@Test fun togglePublishForbiddenSurfacesForbiddenCopy() {
api.unitResponse = errorUnit(403)
api.error = httpError(403)
val vm = viewModel()
vm.togglePublish(AdminPostDto(id = 5, title = "x", published = 1))
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
@Test fun createCategoryRejectsBlankFields() {
val vm = viewModel()
vm.createCategory(slug = "", title = "", description = "", sortOrder = null)
assertEquals(R.string.admin_content_cat_fields_required, vm.state.value.feedback!!.messageRes)
}
@Test fun deletePostNetworkErrorShowsNetworkCopy() {
api.error = java.io.IOException("offline")
val vm = viewModel()
vm.deletePost(9)
assertEquals(R.string.error_network, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,70 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AdminCountsDto
import com.runicgateway.app.data.api.dto.AdminDashboardDto
import com.runicgateway.app.data.api.dto.SiteModeStateDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminDashboardViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminDashboardViewModel(AdminRepository(api))
@Test fun loadsDashboardOnInit() {
api.dashboard = AdminDashboardDto(siteMode = "live", counts = AdminCountsDto(users = 12))
val vm = viewModel()
val state = vm.state.value.dashboard
assertTrue(state is UiState.Success)
assertEquals(12, (state as UiState.Success).data.counts.users)
}
@Test fun loadSurfacesServerErrorAsUiError() {
api.error = httpError(500)
val vm = viewModel()
assertTrue(vm.state.value.dashboard is UiState.Error)
}
@Test fun setSiteModeSuccessUpdatesModeAndClearsSwitching() {
api.dashboard = AdminDashboardDto(siteMode = "live")
api.siteMode = SiteModeStateDto(siteMode = "maintenance")
val vm = viewModel()
vm.setSiteMode("maintenance")
val s = vm.state.value
assertFalse(s.switching)
assertTrue(s.feedback!!.ok)
assertEquals(R.string.admin_site_mode_updated, s.feedback!!.messageRes)
}
@Test fun setSiteModeForbiddenShowsForbiddenCopy() {
api.dashboard = AdminDashboardDto()
api.error = httpError(403)
val vm = viewModel()
vm.setSiteMode("maintenance")
val fb = vm.state.value.feedback!!
assertFalse(fb.ok)
assertEquals(R.string.admin_forbidden, fb.messageRes)
}
@Test fun clearFeedbackResetsBanner() {
api.error = httpError(500)
val vm = viewModel()
vm.setSiteMode("live")
vm.clearFeedback()
assertEquals(null, vm.state.value.feedback)
}
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminModerationViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminModerationViewModel(AdminRepository(api))
@Test fun kickWithNoTargetShowsTargetRequired() {
val vm = viewModel()
vm.kick(account = "", serial = "")
assertEquals(R.string.admin_mod_target_required, vm.state.value.feedback!!.messageRes)
}
@Test fun banForwardsFieldsAndSucceeds() {
val vm = viewModel()
vm.ban(account = "gwen", serial = "", durationSec = 3600, reason = "afk")
assertEquals("gwen", api.lastBan!!.account)
assertNull(api.lastBan!!.serial) // blank -> null
assertEquals(3600L, api.lastBan!!.durationSec)
assertEquals("afk", api.lastBan!!.reason)
val fb = vm.state.value.feedback!!
assertTrue(fb.ok)
assertEquals(R.string.admin_mod_banned, fb.messageRes)
assertFalse(vm.state.value.busy)
}
@Test fun shardOfflineMapsTo503Copy() {
api.unitResponse = errorUnit(503)
val vm = viewModel()
vm.kick(account = "x", serial = "")
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
}
@Test fun broadcastRejectsBlankText() {
val vm = viewModel()
vm.broadcast(text = " ", hue = null)
assertEquals(R.string.admin_mod_text_required, vm.state.value.feedback!!.messageRes)
}
@Test fun unbanForbiddenMapsTo403Copy() {
api.unitResponse = errorUnit(403)
val vm = viewModel()
vm.unban("gwen")
assertEquals(R.string.admin_forbidden, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,61 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.admin
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.SupportPageDto
import com.runicgateway.app.data.api.fake.FakeAdminApi
import com.runicgateway.app.data.repository.AdminRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.errorUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class AdminSupportViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakeAdminApi()
private fun viewModel() = AdminSupportViewModel(AdminRepository(api))
@Test fun loadsOpenPagesOnInit() {
api.supportPages = listOf(SupportPageDto(pageId = "0x1", message = "help"))
val vm = viewModel()
val pages = vm.state.value.pages
assertTrue(pages is UiState.Success)
assertEquals("0x1", (pages as UiState.Success).data.first().pageId)
}
@Test fun respondRejectsBlankMessage() {
val vm = viewModel()
vm.respond(id = "0x1", message = " ", close = true)
assertEquals(R.string.admin_support_message_required, vm.state.value.feedback!!.messageRes)
assertEquals(null, api.lastRespond)
}
@Test fun respondTrimsMessageAndReloadsOnSuccess() {
api.supportPages = listOf(SupportPageDto(pageId = "0x1"))
val vm = viewModel()
vm.respond(id = "0x1", message = " on it ", close = false)
assertEquals("on it", api.lastRespond!!.second.message)
assertTrue(vm.state.value.feedback!!.ok)
}
@Test fun respondUnknownPageMapsTo404Copy() {
api.unitResponse = errorUnit(404)
val vm = viewModel()
vm.respond(id = "0xZ", message = "hi", close = false)
assertEquals(R.string.admin_support_unknown_page, vm.state.value.feedback!!.messageRes)
}
@Test fun closeShardOfflineMapsTo503Copy() {
api.unitResponse = errorUnit(503)
val vm = viewModel()
vm.close("0x1")
assertEquals(R.string.admin_mod_shard_offline, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,67 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.contact
import com.runicgateway.app.data.api.dto.ContactResponse
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.ContactRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class ContactViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePublicApi()
private fun viewModel() = ContactViewModel(ContactRepository(api))
@Test fun blankFieldsProduceValidationError() {
val vm = viewModel()
vm.onNameChange("Gwen")
vm.send() // email + message still blank
assertEquals(ContactViewModel.Result.ValidationError, vm.state.value.result)
assertEquals(null, api.lastContact) // never hit the API
}
@Test fun successfulSendClearsFieldsAndReportsSent() {
api.contactResponse = ContactResponse(sent = true)
val vm = viewModel()
vm.onNameChange(" Gwen ")
vm.onEmailChange(" g@x.c ")
vm.onMessageChange(" hello ")
vm.send()
assertEquals(ContactViewModel.Result.Sent, vm.state.value.result)
assertEquals("", vm.state.value.name) // fields cleared on success
// Repository trims before sending.
assertEquals("Gwen", api.lastContact!!.name)
assertEquals("g@x.c", api.lastContact!!.email)
}
@Test fun mailerFallbackSurfacesEmail() {
api.contactResponse = ContactResponse(sent = false, fallback = "mailto", email = "team@shard.gg")
val vm = viewModel()
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
vm.send()
val result = vm.state.value.result
assertTrue(result is ContactViewModel.Result.Fallback)
assertEquals("team@shard.gg", (result as ContactViewModel.Result.Fallback).email)
// Fields kept (not a clean send) so the user can retry.
assertEquals("A", vm.state.value.name)
}
@Test fun serverErrorSurfacesFailedWithKind() {
api.error = httpError(500)
val vm = viewModel()
vm.onNameChange("A"); vm.onEmailChange("a@b.c"); vm.onMessageChange("m")
vm.send()
val result = vm.state.value.result
assertTrue(result is ContactViewModel.Result.Failed)
assertEquals(ErrorKind.SERVER, (result as ContactViewModel.Result.Failed).kind)
}
}

View File

@@ -0,0 +1,89 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.player
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RosterCharDto
import com.runicgateway.app.data.api.dto.RosterDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.ShardLinkResultDto
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
class CharactersViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val playerApi = FakePlayerShardApi()
private val publicApi = FakePublicApi()
private fun viewModel() = CharactersViewModel(
PlayerShardRepository(playerApi),
SettingsRepository(publicApi),
)
@Test fun loadsAccountsRostersAndSignupFlag() {
playerApi.accounts = listOf(ShardLinkDto(account = "acct1"))
playerApi.roster = RosterDto(acct = "acct1", chars = listOf(RosterCharDto(serial = "0x24C", name = "Gwen")))
publicApi.settings = SettingsDto(gameAccountSignup = true)
val vm = viewModel()
assertEquals(listOf("acct1"), (vm.state.value.accounts as UiState.Success).data)
val roster = vm.state.value.rosters["acct1"]
assertTrue(roster is UiState.Success)
assertEquals("Gwen", (roster as UiState.Success).data.first().name)
assertTrue(vm.state.value.signupEnabled)
}
@Test fun accountsErrorSurfacesError() {
playerApi.error = httpError(503)
assertTrue(viewModel().state.value.accounts is UiState.Error)
}
@Test fun linkBlankCodeIsIgnored() {
val vm = viewModel()
vm.link(" ")
assertEquals(null, vm.state.value.feedback)
}
@Test fun linkSuccessShowsOkAndReloads() {
playerApi.linkResult = ShardLinkResultDto(linked = true, account = "acct1")
val vm = viewModel()
vm.link("CODE1")
val fb = vm.state.value.feedback!!
assertTrue(fb.ok)
assertEquals(R.string.player_link_ok, fb.messageRes)
assertFalse(vm.state.value.busy)
}
@Test fun linkBadCodeMapsTo400Copy() {
playerApi.error = httpError(400)
val vm = viewModel()
vm.link("BAD")
assertEquals(R.string.player_link_bad_code, vm.state.value.feedback!!.messageRes)
}
@Test fun createAccountRejectsShortPasswordWithoutApiCall() {
val vm = viewModel()
vm.createAccount(account = "acct1", password = "short") // < 8 chars
assertEquals(null, vm.state.value.feedback) // guarded before any call/feedback
}
@Test fun createAccountTakenMapsTo409Copy() {
playerApi.error = httpError(409)
val vm = viewModel()
vm.createAccount(account = "acct1", password = "longenough")
assertEquals(R.string.player_create_taken, vm.state.value.feedback!!.messageRes)
}
}

View File

@@ -0,0 +1,83 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.player
import androidx.lifecycle.SavedStateHandle
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.PlayerHouseDto
import com.runicgateway.app.data.api.dto.ShardLinkDto
import com.runicgateway.app.data.api.dto.VendorDto
import com.runicgateway.app.data.api.dto.VendorSaleDto
import com.runicgateway.app.data.api.dto.VendorSnapshotDto
import com.runicgateway.app.data.api.fake.FakePlayerShardApi
import com.runicgateway.app.data.repository.PlayerShardRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/** ViewModels over the player self-service game-data API (own chars, vendors, houses). */
class PlayerViewModelTest {
@get:Rule val mainDispatcher = MainDispatcherRule()
private val api = FakePlayerShardApi()
private val repository = PlayerShardRepository(api)
// ── My houses ───────────────────────────────────────────────────────────
@Test fun myHousesLoadsOwnHouses() {
api.houses = listOf(PlayerHouseDto(serial = "0x40001", name = "Keep", isIdoc = false))
val vm = MyHousesViewModel(repository)
assertEquals("Keep", (vm.state.value as UiState.Success).data.first().name)
}
@Test fun myHousesShardOfflineIsShardOfflineError() {
api.error = httpError(503)
val state = MyHousesViewModel(repository).state.value
assertEquals(ErrorKind.SHARD_OFFLINE, (state as UiState.Error).kind)
}
// ── Character sheet (SavedStateHandle serial) ─────────────────────────────
@Test fun characterLoadsBySerial() {
api.char = CharProfileDto(serial = "0x24C", name = "Gwen", online = true)
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x24C")))
assertEquals("Gwen", (vm.state.value as UiState.Success).data.name)
}
@Test fun characterForbiddenIsNotFound() {
api.error = httpError(403)
val vm = CharacterViewModel(repository, SavedStateHandle(mapOf(Routes.Args.SERIAL to "0x1")))
// 403 isn't a mapped status -> SERVER bucket (only 404/429/503 are special-cased).
assertTrue(vm.state.value is UiState.Error)
}
// ── Vendors (per-account snapshots + sales) ───────────────────────────────
@Test fun vendorsLoadsAccountsThenPerAccountSnapshots() {
api.accounts = listOf(ShardLinkDto(account = "acct1"))
api.vendors = VendorSnapshotDto(acct = "acct1", vendors = listOf(VendorDto(serial = "0x9", shopName = "Wares")))
api.sales = listOf(VendorSaleDto(itemType = "sword", price = 100))
val vm = VendorsViewModel(repository)
val accounts = vm.state.value.accounts
assertTrue(accounts is UiState.Success)
assertEquals(listOf("acct1"), (accounts as UiState.Success).data)
// Each account's vendors loaded into the per-account map.
val perAccount = vm.state.value.vendors["acct1"]
assertTrue(perAccount is UiState.Success)
assertEquals("Wares", (perAccount as UiState.Success).data.first().shopName)
assertTrue(vm.state.value.sales is UiState.Success)
}
@Test fun vendorsAccountsErrorSurfacesError() {
api.error = java.io.IOException("offline")
val vm = VendorsViewModel(repository)
assertTrue(vm.state.value.accounts is UiState.Error)
assertTrue(vm.state.value.sales is UiState.Error)
}
}

View File

@@ -0,0 +1,44 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* [FrameFields] does minimal typed reads from a raw SSE frame object for the
* fields a `*.remove` / `*.decay` delta needs; it must return null (not crash) on
* a missing or ill-typed field so a malformed frame is skipped.
*/
class FrameFieldsTest {
@Test fun longFieldParsesNumericPrimitive() {
val obj = buildJsonObject { put("serial", "12345") }
assertEquals(12345L, FrameFields.longField(obj, "serial"))
}
@Test fun longFieldReturnsNullOnMissingOrNonNumeric() {
val obj = buildJsonObject { put("serial", "0xNaN") }
assertNull(FrameFields.longField(obj, "serial")) // not a Long
assertNull(FrameFields.longField(obj, "absent")) // missing key
}
@Test fun longFieldReturnsNullOnJsonNullOrObject() {
val obj = JsonObject(mapOf("a" to JsonNull, "b" to JsonObject(emptyMap())))
assertNull(FrameFields.longField(obj, "a"))
assertNull(FrameFields.longField(obj, "b"))
}
@Test fun stringFieldReadsPrimitiveContent() {
val obj = JsonObject(mapOf("kind" to JsonPrimitive("champ.remove")))
assertEquals("champ.remove", FrameFields.stringField(obj, "kind"))
assertNull(FrameFields.stringField(obj, "missing"))
}
}

View File

@@ -0,0 +1,123 @@
/*
* 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.ChampDto
import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
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.ShardRepository
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.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/**
* The live-board ViewModels (§6.2): a snapshot load kept live by merging SSE
* frames. Tests use a [FakeShardStream] that emits a fixed script and completes,
* so both the snapshot path and the frame-merge path are covered deterministically.
*/
class ShardBoardViewModelTest {
@get:Rule val mainDispatcher = 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)
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
val stream = FakeShardStream(
listOf(
ShardStreamEvent.Open,
ShardStreamEvent.Frame(
"champ.update",
buildJsonObject { put("serial", "0x2"); put("category", "mini"); put("name", "Barracoon") },
),
ShardStreamEvent.Frame("champ.remove", buildJsonObject { put("serial", "0x1") }),
),
)
val vm = ChampsViewModel(repo(stream))
val rows = (vm.state.value as UiState.Success).data
// 0x1 removed, 0x2 upserted.
assertEquals(listOf("0x2"), rows.map { it.serial })
assertTrue(vm.connected.value) // Open was seen
}
@Test fun champsServerErrorIsUiError() {
api.error = httpError(503)
assertTrue(ChampsViewModel(repo()).state.value is UiState.Error)
}
// ── Guilds ────────────────────────────────────────────────────────────
@Test fun guildsSnapshotThenLiveUpdate() {
api.guilds = listOf(GuildDto(id = 1, name = "Knights"))
val stream = FakeShardStream(
listOf(ShardStreamEvent.Frame("guild.update", buildJsonObject { put("id", 2); put("name", "Mages") })),
)
val vm = GuildsViewModel(repo(stream))
val names = (vm.state.value as UiState.Success).data.map { it.name }
assertTrue(names.contains("Knights"))
assertTrue(names.contains("Mages"))
}
// ── Governors (+ history) ─────────────────────────────────────────────
@Test fun governorsSnapshotAndHistory() {
api.governors = listOf(GovernorDto(city = "Britain"))
api.governorHistory = listOf() // empty is fine
val vm = GovernorsViewModel(repo())
assertTrue(vm.state.value is UiState.Success)
vm.loadHistory("Britain")
assertTrue(vm.history.value.containsKey("Britain"))
}
// ── Houses (IDOC board) ───────────────────────────────────────────────
@Test fun housesSnapshotLoads() {
api.houses = listOf(HouseDto(serial = "0x40", name = "Tower", isIdoc = true))
val vm = HousesViewModel(repo())
assertEquals("Tower", (vm.state.value as UiState.Success).data.first().name)
}
@Test fun housesNetworkErrorIsUiError() {
api.error = java.io.IOException("offline")
assertTrue(HousesViewModel(repo()).state.value is UiState.Error)
}
// ── Shard hub (status primary, presence/online best-effort, live feed) ─
@Test fun shardHubLoadsStatusPresenceOnlineAndSeedsFeed() {
api.shardStatus = ShardStatusDto(enabled = true, pluginConnected = true, onlineCount = 5)
api.shardPresence = PresenceDto(count = 5)
val stream = FakeShardStream(
listOf(
ShardStreamEvent.Closed,
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
),
)
val vm = ShardViewModel(repo(stream))
val hub = (vm.state.value as UiState.Success).data
assertTrue(hub.status.isOnline)
// presence.online frame patched the count in place.
assertEquals(9, hub.presence!!.count)
assertFalse(vm.connected.value) // last lifecycle event was Closed
}
@Test fun shardHubStatusErrorIsUiError() {
api.error = httpError(503)
assertTrue(ShardViewModel(repo()).state.value is UiState.Error)
}
}

View File

@@ -0,0 +1,27 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.util
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.ResponseBody.Companion.toResponseBody
import retrofit2.HttpException
import retrofit2.Response
/**
* Test doubles emulate Retrofit's error contract: a suspend API method throws
* [HttpException] on a non-2xx and an [java.io.IOException] on a transport failure,
* exactly what `safeApiCall` folds into `ApiResult.HttpError` / `NetworkError`.
*/
private val JSON = "application/json".toMediaTypeOrNull()
/** An [HttpException] carrying [code] — what a fake API throws to drive an HTTP-error path. */
fun httpError(code: Int): HttpException =
HttpException(Response.error<Any>(code, "{}".toResponseBody(JSON)))
/** A successful bodyless [Response] (for `@DELETE`/moderation endpoints returning `Response<Unit>`). */
fun okUnit(): Response<Unit> = Response.success(Unit)
/** A non-2xx bodyless [Response] with [code]. */
fun errorUnit(code: Int): Response<Unit> = Response.error(code, "{}".toResponseBody(JSON))

View File

@@ -0,0 +1,31 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.util
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestDispatcher
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import org.junit.rules.TestWatcher
import org.junit.runner.Description
/**
* Swaps `Dispatchers.Main` (which `viewModelScope` dispatches on) for a test
* dispatcher for the duration of a test, so ViewModel coroutines run on the test
* scheduler instead of a real Android main looper.
*
* Defaults to an [UnconfinedTestDispatcher] so work launched from a ViewModel's
* `init {}` runs eagerly to its first real suspension — with fakes that never
* truly suspend, that means the final state is settled by the time the constructor
* returns, and a test can assert `state.value` directly without advancing time.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class MainDispatcherRule(
val dispatcher: TestDispatcher = UnconfinedTestDispatcher(),
) : TestWatcher() {
override fun starting(description: Description) = Dispatchers.setMain(dispatcher)
override fun finished(description: Description) = Dispatchers.resetMain()
}

View File

@@ -26,16 +26,30 @@ sonar.sourceEncoding=UTF-8
# coverage even though the JVM unit suite (app/src/test) exists.
sonar.coverage.jacoco.xmlReportPaths=app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml
# Exclude from *coverage* (not from analysis): pure-@Composable UI can't be exercised
# by JVM unit tests without Robolectric, so counting those lines would unfairly sink
# new-code coverage. Logic (ViewModels, repositories, core, DTOs) stays measured.
# Exclude from *coverage* (not from analysis — bugs/smells are still reported): code a
# JVM unit test physically can't execute, so counting its lines would unfairly sink
# new-code coverage. Two kinds: pure-@Composable UI (needs Robolectric/instrumented
# tests), and Android-framework glue (Keystore-backed stores, foreground push service,
# notifications, Hilt modules). Testable logic — ViewModels, repositories, DTOs, and
# pure core/ code — stays measured. See docs/android/COVERAGE_PLAN.md §1.
sonar.coverage.exclusions=\
app/src/main/java/**/ui/**/*Screen.kt,\
app/src/main/java/**/ui/**/*Screen*.kt,\
app/src/main/java/**/ui/theme/**,\
app/src/main/java/**/ui/components/**,\
app/src/main/java/**/ui/page/BlockRenderer.kt,\
app/src/main/java/**/ui/shard/ShardComponents.kt,\
app/src/main/java/**/ui/LocalAssetResolver.kt,\
app/src/main/java/**/RunicApp.kt,\
app/src/main/java/**/MainActivity.kt,\
app/src/main/java/**/*Application.kt
app/src/main/java/**/*Application.kt,\
app/src/main/java/**/RunicGatewayApp.kt,\
app/src/main/java/**/di/**,\
app/src/main/java/**/core/push/PushService.kt,\
app/src/main/java/**/core/push/PushManager.kt,\
app/src/main/java/**/core/push/PushNotifier.kt,\
app/src/main/java/**/core/push/NtfyStreamClient.kt,\
app/src/main/java/**/core/auth/Encrypted*.kt
# ── Optional enrichment (enable once produced in CI) ──
# • Android Lint: ./gradlew lintDebug → app/build/reports/lint-results-debug.xml