From 4e3bb914ff694c140c855cf92112044df854e2a1 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 22 Jul 2026 16:04:02 -0500 Subject: [PATCH] test(coverage): raise unit coverage past the 50% gate (phases 0-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Executes COVERAGE_PLAN.md phases 0-2 to clear the SonarQube new-code coverage gate (was 16.4%, threshold 50%). Estimated new-code coverage after this change is ~57%. 109 new tests across 19 files; full suite is 264 tests, all green. Phase 0 — coverage exclusions (sonar-project.properties): drop code a JVM unit test can't execute from the *coverage* denominator (still analysed for bugs/smells) — pure-@Composable UI the `*Screen.kt` glob missed (ui/components/**, BlockRenderer, ShardComponents), Android-framework glue (push services, Keystore-backed Encrypted* stores, Hilt di/**). Phase 1 — DTO serialization tests: AdminDto, PublicDto, WikiDto, PostDto/PageDto/ ContactDto, SsoDto, the shard board DTOs and player game-data DTOs, and the mobile-auth request bodies — decode + encode + computed helpers (isPublished/isMaintenance/ActorDto.label/ShardStatusDto.isOnline). Phase 2 — ViewModel tests: a MainDispatcherRule harness + hand-written API fakes (FakePublicApi/FakeAdminApi/FakePlayerShardApi/FakeShardStream) drive real repositories into the ViewModels. Covers the admin (dashboard/content/moderation/ support), content (news/post/page/wiki/home/contact), player (characters/ vendors/character/my-houses) and shard-board (champs/guilds/governors/houses/ hub) ViewModels — load success/error, form validation, role/status-aware feedback, and live-frame merging. To make the shard boards testable, extract a small `ShardStream` interface from `ShardStreamClient` (bound in NetworkModule) so `ShardRepository` depends on the capability, not the OkHttp client — lets a fake stream replace the perpetual SSE reconnect loop in tests. No production behaviour change. Phases 3 (repositories) and 4 (core net/auth top-up) are follow-ups; the deep-dependency auth family (Login/Account/TrustedDevices ViewModels, AuthRepository) lands with them. See docs/android/COVERAGE_PLAN.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr --- .../runicgateway/app/core/net/ShardStream.kt | 16 +++ .../app/core/net/ShardStreamClient.kt | 4 +- .../app/data/repository/ShardRepository.kt | 4 +- .../com/runicgateway/app/di/NetworkModule.kt | 8 ++ .../app/core/result/ApiResultExtrasTest.kt | 41 ++++++ .../app/data/api/dto/AdminDtoTest.kt | 127 ++++++++++++++++++ .../app/data/api/dto/AuthRequestDtoTest.kt | 68 ++++++++++ .../app/data/api/dto/ContentDtoTest.kt | 82 +++++++++++ .../app/data/api/dto/PlayerGameDataDtoTest.kt | 115 ++++++++++++++++ .../app/data/api/dto/PublicDtoTest.kt | 73 ++++++++++ .../app/data/api/dto/ShardBoardDtoTest.kt | 104 ++++++++++++++ .../app/data/api/dto/SsoDtoTest.kt | 46 +++++++ .../app/data/api/dto/WikiDtoTest.kt | 66 +++++++++ .../app/data/api/fake/FakeAdminApi.kt | 88 ++++++++++++ .../app/data/api/fake/FakePlayerShardApi.kt | 47 +++++++ .../app/data/api/fake/FakePublicApi.kt | 98 ++++++++++++++ .../app/data/api/fake/FakeShardStream.kt | 19 +++ .../app/ui/ContentViewModelTest.kt | 120 +++++++++++++++++ .../app/ui/admin/AdminContentViewModelTest.kt | 78 +++++++++++ .../ui/admin/AdminDashboardViewModelTest.kt | 70 ++++++++++ .../ui/admin/AdminModerationViewModelTest.kt | 63 +++++++++ .../app/ui/admin/AdminSupportViewModelTest.kt | 61 +++++++++ .../app/ui/contact/ContactViewModelTest.kt | 67 +++++++++ .../app/ui/player/CharactersViewModelTest.kt | 89 ++++++++++++ .../app/ui/player/PlayerViewModelTest.kt | 83 ++++++++++++ .../app/ui/shard/FrameFieldsTest.kt | 44 ++++++ .../app/ui/shard/ShardBoardViewModelTest.kt | 123 +++++++++++++++++ .../runicgateway/app/util/FakeApiSupport.kt | 27 ++++ .../app/util/MainDispatcherRule.kt | 31 +++++ sonar-project.properties | 22 ++- 30 files changed, 1876 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/core/net/ShardStream.kt create mode 100644 app/src/test/java/com/runicgateway/app/core/result/ApiResultExtrasTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/AdminDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/AuthRequestDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/ContentDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/PlayerGameDataDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/PublicDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/ShardBoardDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/SsoDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/dto/WikiDtoTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/fake/FakeAdminApi.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerShardApi.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/fake/FakeShardStream.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/player/PlayerViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/shard/FrameFieldsTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/shard/ShardBoardViewModelTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt create mode 100644 app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt diff --git a/app/src/main/java/com/runicgateway/app/core/net/ShardStream.kt b/app/src/main/java/com/runicgateway/app/core/net/ShardStream.kt new file mode 100644 index 0000000..a4aee11 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/net/ShardStream.kt @@ -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 +} diff --git a/app/src/main/java/com/runicgateway/app/core/net/ShardStreamClient.kt b/app/src/main/java/com/runicgateway/app/core/net/ShardStreamClient.kt index 1a5893d..50f1b56 100644 --- a/app/src/main/java/com/runicgateway/app/core/net/ShardStreamClient.kt +++ b/app/src/main/java/com/runicgateway/app/core/net/ShardStreamClient.kt @@ -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 = channelFlow { + override fun events(): Flow = channelFlow { var backoffMs = INITIAL_BACKOFF_MS while (isActive) { val url = baseUrlHolder.current?.resolve(STREAM_PATH) diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt index 81e0739..0856515 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/ShardRepository.kt @@ -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 ──────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index 8a0a8a2..ecbb819 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -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) diff --git a/app/src/test/java/com/runicgateway/app/core/result/ApiResultExtrasTest.kt b/app/src/test/java/com/runicgateway/app/core/result/ApiResultExtrasTest.kt new file mode 100644 index 0000000..8340996 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/core/result/ApiResultExtrasTest.kt @@ -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 = ApiResult.HttpError(500, "boom") + assertSame(http, http.map { it + 1 }) + + val cause = RuntimeException("offline") + val network: ApiResult = 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()) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/AdminDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/AdminDtoTest.kt new file mode 100644 index 0000000..3c0f1a4 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/AdminDtoTest.kt @@ -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( + """{ + "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("{}") + assertEquals("live", dto.siteMode) + assertTrue(dto.counts.posts.isEmpty()) + assertTrue(dto.recentActivity.isEmpty()) + } + + @Test fun adminPostBridgesPublishedTinyintToBoolean() { + val published = json.decodeFromString( + """{"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("""{"id":2,"title":"Draft","published":0}""") + assertFalse(draft.isPublished) + } + + @Test fun adminWikiCategoryAndTagDecodeCounts() { + val cat = json.decodeFromString( + """{"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("""{"id":8,"slug":"pvp","label":"PvP","published_count":11}""") + assertEquals("PvP", tag.label) + assertEquals(11, tag.publishedCount) + } + + @Test fun supportPageDecodesSenderActor() { + val dto = json.decodeFromString( + """{"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("""{"pageId":"0x01"}""") + assertNull(dto.sender) + assertNull(dto.type) + } + + @Test fun siteModeStateDecodesAudit() { + val dto = json.decodeFromString( + """{"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\"")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/AuthRequestDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/AuthRequestDtoTest.kt new file mode 100644 index 0000000..36ccd36 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/AuthRequestDtoTest.kt @@ -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( + """{"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( + """{"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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/ContentDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/ContentDtoTest.kt new file mode 100644 index 0000000..ba17e80 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/ContentDtoTest.kt @@ -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( + """{"id":10,"category":"news","title":"Update","slug":"update", + "excerpt":"e","body":"

full

","image_url":"/i.png", + "published_at":"2026-07-21T00:00:00Z","created_at":"2026-07-20T00:00:00Z"}""", + ) + assertEquals(10L, dto.id) + assertEquals("

full

", dto.body) + assertEquals("/i.png", dto.imageUrl) + assertEquals("2026-07-21T00:00:00Z", dto.publishedAt) + } + + @Test fun postListRowToleratesMissingBody() { + val dto = json.decodeFromString("""{"id":11,"category":"newsletter","title":"N"}""") + assertNull(dto.body) + assertNull(dto.imageUrl) + } + + @Test fun pageDecodesBlocksWithRawProps() { + val dto = json.decodeFromString( + """{ + "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("""{"sent":true}""") + assertTrue(sent.sent) + assertNull(sent.fallback) + + val fallback = json.decodeFromString( + """{"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\"")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerGameDataDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerGameDataDtoTest.kt new file mode 100644 index 0000000..abf162f --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/PlayerGameDataDtoTest.kt @@ -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("""{"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( + """{"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( + """{"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( + """{ + "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("""{"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( + """{"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( + """{"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( + """{"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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/PublicDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/PublicDtoTest.kt new file mode 100644 index 0000000..64b10af --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/PublicDtoTest.kt @@ -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( + """{"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("""{"mode":"live"}""").isMaintenance) + } + + @Test fun statusDefaultsWhenEmpty() { + val dto = json.decodeFromString("{}") + assertEquals("live", dto.mode) + assertFalse(dto.isMaintenance) + assertEquals("", dto.version.api) + } + + @Test fun settingsDecodesBrandRegistrationAndPush() { + val dto = json.decodeFromString( + """{ + "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("""{"site_title":"Bare"}""") + assertFalse(dto.registration.password) + assertEquals("", dto.brand.name) + assertEquals(null, dto.push.ntfyUrl) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/ShardBoardDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardBoardDtoTest.kt new file mode 100644 index 0000000..c2949f8 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/ShardBoardDtoTest.kt @@ -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("""{"name":"Gwen","acct":"g01"}""").label) + assertEquals("g01", json.decodeFromString("""{"acct":"g01"}""").label) + assertEquals("Someone", json.decodeFromString("{}").label) + } + + @Test fun shardStatusIsOnlineOnlyWhenEnabledAndPluginConnected() { + val online = json.decodeFromString( + """{"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("""{"enabled":true,"pluginConnected":false}""").isOnline) + assertFalse(json.decodeFromString("{}").isOnline) + } + + @Test fun champDecodesBossAndProgressFields() { + val dto = json.decodeFromString( + """{"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( + """{"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( + """{"city":"Britain","governor":{"name":"Dawn"},"electionPhase":"campaign"}""", + ) + assertEquals("Britain", gov.city) + assertEquals("Dawn", gov.governor!!.label) + + val term = json.decodeFromString( + """{"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( + """{"serial":"0x40","name":"Tower","region":"Britain","isIdoc":true,"x":5,"y":6}""", + ) + assertTrue(house.isIdoc) + assertEquals("Tower", house.name) + + val presence = json.decodeFromString( + """{"count":30,"byFacet":{"Felucca":10,"Trammel":20},"byRegion":{"Britain":5}}""", + ) + assertEquals(30, presence.count) + assertEquals(10, presence.byFacet["Felucca"]) + + val staff = json.decodeFromString("""{"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( + """{"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")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/SsoDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/SsoDtoTest.kt new file mode 100644 index 0000000..6361187 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/SsoDtoTest.kt @@ -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( + """{"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("""{"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\"")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/dto/WikiDtoTest.kt b/app/src/test/java/com/runicgateway/app/data/api/dto/WikiDtoTest.kt new file mode 100644 index 0000000..753f13b --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/dto/WikiDtoTest.kt @@ -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( + """{"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( + """{ + "id":9,"slug":"housing","title":"Housing","body":"

text

", + "category_slug":"systems","category_title":"Systems", + "tags":[{"slug":"idoc","label":"IDOC"}], + "backlinks":[{"slug":"pvp","title":"PvP"}], + "missing_links":["nonexistent-page"] + }""", + ) + assertEquals("

text

", 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("""{"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( + """{"id":2,"slug":"systems","title":"Systems","description":"d","published_count":12}""", + ) + assertEquals(12L, cat.publishedCount) + val tag = json.decodeFromString("""{"id":4,"slug":"idoc","label":"IDOC","published_count":3}""") + assertEquals(3L, tag.publishedCount) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeAdminApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeAdminApi.kt new file mode 100644 index 0000000..b0382d4 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeAdminApi.kt @@ -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` + * 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 = emptyList() + var createdPost: AdminPostDto = AdminPostDto(id = 0) + var publishedPost: AdminPostDto = AdminPostDto(id = 0) + var wikiCategories: List = emptyList() + var createdCategory: AdminWikiCategoryDto = AdminWikiCategoryDto(id = 0) + var wikiTags: List = emptyList() + var supportPages: List = emptyList() + + /** Response returned by the bodyless write endpoints (kick/ban/delete/respond/…). */ + var unitResponse: Response = 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? = null + + private fun 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 = 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 = reply(unitResponse) + + override suspend fun wikiCategories(): List = reply(wikiCategories) + override suspend fun createWikiCategory(body: WikiCategoryRequest): AdminWikiCategoryDto = reply(createdCategory) + override suspend fun deleteWikiCategory(id: Long): Response = reply(unitResponse) + override suspend fun wikiTags(): List = reply(wikiTags) + + override suspend fun kick(body: KickRequest): Response = reply(unitResponse) + override suspend fun ban(body: BanRequest): Response { + lastBan = body + return reply(unitResponse) + } + override suspend fun unban(body: UnbanRequest): Response = reply(unitResponse) + override suspend fun broadcast(body: BroadcastRequest): Response = reply(unitResponse) + + override suspend fun supportPages(): List = reply(supportPages) + override suspend fun respondPage(id: String, body: PageRespondRequest): Response { + lastRespond = id to body + return reply(unitResponse) + } + override suspend fun closePage(id: String): Response = reply(unitResponse) +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerShardApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerShardApi.kt new file mode 100644 index 0000000..2fb4771 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePlayerShardApi.kt @@ -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 = emptyList() + var roster: RosterDto = RosterDto() + var char: CharProfileDto = CharProfileDto() + var vendors: VendorSnapshotDto = VendorSnapshotDto() + var sales: List = emptyList() + var houses: List = emptyList() + + private fun 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 = 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 = reply(sales) + override suspend fun houses(): List = reply(houses) +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt new file mode 100644 index 0000000..c09a842 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt @@ -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 = emptyList() + var post: PostDto = PostDto(id = 0) + var page: PageDto = PageDto(id = 0) + var wikiPages: List = emptyList() + var wikiCategories: List = emptyList() + var wikiTags: List = emptyList() + var wikiPage: WikiPageDto = WikiPageDto(id = 0) + var contactResponse: ContactResponse = ContactResponse(sent = true) + var shardStatus: ShardStatusDto = ShardStatusDto() + var shardFeed: List = emptyList() + var shardEconomy: List = emptyList() + var shardOnline: List = emptyList() + var shardPresence: PresenceDto = PresenceDto() + var champs: List = emptyList() + var guilds: List = emptyList() + var governors: List = emptyList() + var governorHistory: List = emptyList() + var houses: List = emptyList() + + /** Last contact request body seen (so a test can assert it was trimmed/forwarded). */ + var lastContact: ContactRequest? = null + + private fun 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 = 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 = + reply(wikiPages) + override suspend fun getWikiCategories(): List = reply(wikiCategories) + override suspend fun getWikiTags(): List = 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 = reply(shardFeed) + override suspend fun getShardEconomy(limit: Int?): List = reply(shardEconomy) + override suspend fun getShardOnline(): List = reply(shardOnline) + override suspend fun getShardPresence(): PresenceDto = reply(shardPresence) + override suspend fun getShardChamps(): List = reply(champs) + override suspend fun getShardGuilds(): List = reply(guilds) + override suspend fun getShardGovernors(): List = reply(governors) + override suspend fun getShardGovernorHistory(city: String, limit: Int?): List = + reply(governorHistory) + override suspend fun getShardHouses(): List = reply(houses) +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeShardStream.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeShardStream.kt new file mode 100644 index 0000000..1fe845c --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeShardStream.kt @@ -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 = emptyList()) : ShardStream { + override fun events(): Flow = flowOf(*events.toTypedArray()) +} diff --git a/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt new file mode 100644 index 0000000..fedb2da --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/ContentViewModelTest.kt @@ -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 = "

x

") + 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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt new file mode 100644 index 0000000..82d81c8 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminContentViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt new file mode 100644 index 0000000..ee8a1fc --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminDashboardViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt new file mode 100644 index 0000000..b73e79c --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminModerationViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt new file mode 100644 index 0000000..a4dfb86 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/admin/AdminSupportViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt new file mode 100644 index 0000000..ace0ca2 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/contact/ContactViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt new file mode 100644 index 0000000..025b75d --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/player/CharactersViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/player/PlayerViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/player/PlayerViewModelTest.kt new file mode 100644 index 0000000..400e7bc --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/player/PlayerViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/shard/FrameFieldsTest.kt b/app/src/test/java/com/runicgateway/app/ui/shard/FrameFieldsTest.kt new file mode 100644 index 0000000..8081f5b --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/shard/FrameFieldsTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/shard/ShardBoardViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/shard/ShardBoardViewModelTest.kt new file mode 100644 index 0000000..7c985ce --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/shard/ShardBoardViewModelTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt b/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt new file mode 100644 index 0000000..ab21960 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/util/FakeApiSupport.kt @@ -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(code, "{}".toResponseBody(JSON))) + +/** A successful bodyless [Response] (for `@DELETE`/moderation endpoints returning `Response`). */ +fun okUnit(): Response = Response.success(Unit) + +/** A non-2xx bodyless [Response] with [code]. */ +fun errorUnit(code: Int): Response = Response.error(code, "{}".toResponseBody(JSON)) diff --git a/app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt b/app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt new file mode 100644 index 0000000..ce2c7d5 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/util/MainDispatcherRule.kt @@ -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() +} diff --git a/sonar-project.properties b/sonar-project.properties index 5078f11..e22a60b 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -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 -- 2.49.1