feat(m1): connect & browse — first-run flow, public content, contact
Some checks failed
PR Checks / android-build (pull_request) Failing after 33m35s

Implements M1 (functional Kotlin pass, docs/android/PLAN.md §9): the
first-run base-URL connect flow, brand-seeded Material 3 theming from
/public/settings, a Retrofit/OkHttp/kotlinx-serialization client with a
runtime host-selection interceptor (the base URL is not compiled in),
the layered repository stack returning a typed ApiResult for graceful
degradation, and functional Compose screens for Home/Status, News
(+ post detail), Wiki (+ detail), CMS pages (block renderer), and the
contact form. One shared, declarative navigation drawer. No auth yet (M3).

DTOs + the Retrofit interface are hand-written and spec-aligned rather
than openapi-generated: the committed swagger-output.json is produced by
swagger-autogen and its component schemas are meta-descriptive (nested
{type, example} wrappers), not codegen-clean, so a hand-authored client
module is the pragmatic "checked-in generated module" the plan allows
(§2). Shapes were matched against the website controllers/models.

JVM unit tests cover URL normalization, host rewriting, ApiResult/UiState
mapping, and brand-color parsing. `lint test assembleDebug` green locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
2026-07-19 17:09:33 -05:00
parent 91cd4585c0
commit 9019ded556
55 changed files with 3275 additions and 52 deletions

View File

@@ -0,0 +1,59 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* Unit tests for retargeting a sentinel-hosted request onto the configured base
* URL (PLAN.md §3). Covers the base-path-prefix case that a naive host swap gets
* wrong.
*/
class HostRewriteTest {
private fun rewrite(base: String, request: String) =
rewriteOntoBase(base.toHttpUrl(), request.toHttpUrl()).toString()
@Test fun swapsHostForRootBase() {
assertEquals(
"https://shard.example.com/api/v1/public/status",
rewrite(
base = "https://shard.example.com/",
request = "https://runic-gateway.invalid/api/v1/public/status",
),
)
}
@Test fun preservesBasePathPrefixWithoutDoubleSlash() {
assertEquals(
"https://host.example.com/uo/api/v1/public/status",
rewrite(
base = "https://host.example.com/uo/",
request = "https://runic-gateway.invalid/api/v1/public/status",
),
)
}
@Test fun preservesQueryString() {
assertEquals(
"https://shard.example.com/api/v1/public/wiki?q=magic",
rewrite(
base = "https://shard.example.com/",
request = "https://runic-gateway.invalid/api/v1/public/wiki?q=magic",
),
)
}
@Test fun keepsPortAndScheme() {
assertEquals(
"http://127.0.0.1:3000/api/v1/public/status",
rewrite(
base = "http://127.0.0.1:3000/",
request = "https://runic-gateway.invalid/api/v1/public/status",
),
)
}
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Unit tests for the first-run base-URL normalization (PLAN.md §3). */
class ServerUrlTest {
private fun valid(raw: String, allowInsecure: Boolean = true) =
(ServerUrl.normalize(raw, allowInsecure) as ServerUrl.Result.Valid).url
private fun invalid(raw: String, allowInsecure: Boolean = true) =
(ServerUrl.normalize(raw, allowInsecure) as ServerUrl.Result.Invalid).reason
@Test fun blankIsRejected() {
assertEquals(ServerUrl.Reason.BLANK, invalid(""))
assertEquals(ServerUrl.Reason.BLANK, invalid(" "))
}
@Test fun nonHttpSchemeIsRejected() {
assertEquals(ServerUrl.Reason.UNSUPPORTED_SCHEME, invalid("ftp://example.com"))
assertEquals(ServerUrl.Reason.UNSUPPORTED_SCHEME, invalid("ws://example.com"))
}
@Test fun garbageIsMalformed() {
assertEquals(ServerUrl.Reason.MALFORMED, invalid("has spaces in it"))
}
@Test fun httpRejectedWhenInsecureDisallowed() {
assertEquals(ServerUrl.Reason.INSECURE, invalid("http://example.com", allowInsecure = false))
}
@Test fun httpAllowedInDebug() {
val url = valid("http://127.0.0.1:3000", allowInsecure = true)
assertEquals("http", url.scheme)
assertEquals(3000, url.port)
}
@Test fun schemelessInputGetsHttps() {
val url = valid("shard.example.com", allowInsecure = false)
assertEquals("https", url.scheme)
assertEquals("shard.example.com", url.host)
assertEquals("/", url.encodedPath)
}
@Test fun trailingSlashIsEnforced() {
assertTrue(valid("https://example.com").toString().endsWith("/"))
assertEquals("/base/", valid("https://example.com/base").encodedPath)
assertEquals("/base/", valid("https://example.com/base/").encodedPath)
}
@Test fun queryAndFragmentAreStripped() {
val url = valid("https://example.com/base?token=abc#frag")
assertEquals("/base/", url.encodedPath)
assertNull(url.query)
assertEquals("https://example.com/base/", url.toString())
}
}

View File

@@ -0,0 +1,51 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.result
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import retrofit2.HttpException
import retrofit2.Response
import java.io.IOException
/** Unit tests for the typed-result plumbing (PLAN.md §7). */
class ApiResultTest {
@Test fun okWrapsValue() = runTest {
val result = safeApiCall { 42 }
assertEquals(ApiResult.Ok(42), result)
}
@Test fun httpExceptionBecomesHttpError() = runTest {
val result = safeApiCall {
throw HttpException(Response.error<Any>(404, "nope".toResponseBody(null)))
}
assertTrue(result is ApiResult.HttpError)
assertEquals(404, (result as ApiResult.HttpError).status)
}
@Test fun ioExceptionBecomesNetworkError() = runTest {
val result = safeApiCall { throw IOException("offline") }
assertTrue(result is ApiResult.NetworkError)
}
@Test fun cancellationIsRethrown() = runTest {
assertThrows(CancellationException::class.java) {
kotlinx.coroutines.runBlocking {
safeApiCall { throw CancellationException("cancelled") }
}
}
}
@Test fun mapTransformsOnlyOk() {
assertEquals(ApiResult.Ok(4), ApiResult.Ok(2).map { it * 2 })
val err: ApiResult<Int> = ApiResult.HttpError(500)
assertEquals(err, err.map { it * 2 })
}
}

View File

@@ -0,0 +1,39 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
/** Unit tests for folding an [ApiResult] into a screen [UiState] (PLAN.md §7). */
class UiStateTest {
@Test fun okBecomesSuccess() {
assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toUiState())
}
@Test fun networkErrorMapsToNetworkKind() {
val state = ApiResult.NetworkError(IOException()).toUiState()
assertEquals(ErrorKind.NETWORK, (state as UiState.Error).kind)
}
@Test fun knownStatusesMapToKinds() {
assertEquals(ErrorKind.NOT_FOUND, kindOf(404))
assertEquals(ErrorKind.RATE_LIMITED, kindOf(429))
assertEquals(ErrorKind.SHARD_OFFLINE, kindOf(503))
assertEquals(ErrorKind.SERVER, kindOf(500))
}
@Test fun httpStatusIsPreserved() {
val state = ApiResult.HttpError(503).toUiState() as UiState.Error
assertEquals(503, state.httpStatus)
assertTrue(ApiResult.HttpError(503).let { it.status == 503 })
}
private fun kindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
}

View File

@@ -0,0 +1,41 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/** Unit tests for brand accent parsing (PLAN.md §3, §5). */
class BrandColorTest {
@Test fun nullOrBlankReturnsNull() {
assertNull(parseBrandColor(null))
assertNull(parseBrandColor(""))
assertNull(parseBrandColor(" "))
}
@Test fun sixDigitHexParses() {
assertEquals(Color(0xFF7F99BD), parseBrandColor("#7f99bd"))
}
@Test fun hashIsOptional() {
assertEquals(parseBrandColor("#7f99bd"), parseBrandColor("7f99bd"))
}
@Test fun shorthandExpands() {
assertEquals(parseBrandColor("#ffffff"), parseBrandColor("#fff"))
}
@Test fun eightDigitHexKeepsAlpha() {
assertEquals(Color(0x80112233), parseBrandColor("#80112233"))
}
@Test fun invalidReturnsNull() {
assertNull(parseBrandColor("#12"))
assertNull(parseBrandColor("nothex!"))
assertNull(parseBrandColor("#gggggg"))
}
}