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
Some checks failed
SonarQube / analysis (push) Has been cancelled
Reviewed-on: #27 Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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\""))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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\""))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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\""))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user