feat(m2): public shard widgets + live SSE stream
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m4s

Implements M2 of docs/android/PLAN.md §6.2 (functional pass): the public
shard surface over /api/v1/public/shard/*, plus the live SSE feed with
reconnect/backoff and graceful degradation (§7).

- Shard DTOs (status/economy/feed/online/presence/champs/guilds/governors/
  houses) mirroring public/shard.controller.js; ignoreUnknownKeys keeps
  additive backend fields safe, and the live *.update frames decode into the
  same board DTOs.
- PublicApi: the /public/shard/* GETs (status, feed, economy, online,
  presence, champs, guilds, governors + history, houses).
- ShardStreamClient: OkHttp SSE over /public/shard/stream. Unlike the browser
  EventSource it reconnects itself — a cold Flow<ShardStreamEvent> with
  growing backoff (reset on open), no read timeout for the idle keepalive,
  and clean teardown on cancel so a dropped feed degrades to "offline".
- ShardRepository: typed ApiResult snapshot reads + the shared live feed and
  frame decoders.
- Screens: a Shard hub (status/online count/economy/presence/staff + live
  activity feed with a live indicator) linking to live boards for champion
  spawns, guilds, governors (+ on-demand term history) and falling houses
  (IDOC). Boards seed from a snapshot then merge SSE deltas in place via a
  reusable LiveBoard, mirroring the website's merge semantics. Wired into the
  shared navigation drawer (§5); all strings externalized (§2).
- Tests (28): DTO/frame decode, LiveBoard merge, event-text formatting, and
  SSE frame parsing.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-19 18:34:14 -05:00
parent 81f10fbca4
commit c8f4e76370
26 changed files with 2192 additions and 3 deletions

View File

@@ -0,0 +1,53 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.core.net
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Unit tests for SSE frame parsing (PLAN.md §6.2). The reconnect/backoff loop and
* OkHttp EventSource wiring are integration concerns; the pure parse of a `data:`
* payload into `(kind, object)` is unit-testable here.
*/
class ShardStreamClientTest {
private val client = ShardStreamClient(
baseClient = OkHttpClient(),
baseUrlHolder = BaseUrlHolder(),
json = Json { ignoreUnknownKeys = true },
)
@Test fun parsesKindAndKeepsObject() {
val parsed = client.parseFrame("""{"kind":"champ.update","serial":5,"status":"active"}""")
assertEquals("champ.update", parsed?.first)
assertEquals("5", parsed?.second?.get("serial").toString())
}
@Test fun dropsKeepaliveComment() {
assertNull(client.parseFrame(": ping"))
assertNull(client.parseFrame(": connected"))
}
@Test fun dropsBlank() {
assertNull(client.parseFrame(" "))
assertNull(client.parseFrame(""))
}
@Test fun dropsFrameWithoutKind() {
assertNull(client.parseFrame("""{"serial":5}"""))
}
@Test fun dropsNullKind() {
assertNull(client.parseFrame("""{"kind":null}"""))
}
@Test fun dropsMalformedJson() {
assertNull(client.parseFrame("""{"kind":"x" """))
assertNull(client.parseFrame("not json"))
}
}

View File

@@ -0,0 +1,100 @@
/*
* 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.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decoding tests for the shard DTOs (PLAN.md §6.2). Shapes come from the website's
* `public/shard.controller.js`; the parser must ignore unknown keys (additive
* backend fields, §8) and decode the live `*.update` SSE frames — which carry a
* `kind` and category-specific extras — into the same board DTOs.
*/
class ShardDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun statusDecodesAndDerivesOnline() {
val dto = json.decodeFromString<ShardStatusDto>(
"""{"enabled":true,"status":"connected","pluginConnected":true,
"lastEventAt":"2026-07-19T22:00:00Z","onlineCount":42,
"economy":{"accounts":900,"gold":123456789.0,"t":1721426400000}}""",
)
assertTrue(dto.isOnline)
assertEquals(42, dto.onlineCount)
assertEquals(123456789.0, dto.economy?.gold!!, 0.0)
}
@Test fun statusOfflineWhenPluginDisconnected() {
val dto = json.decodeFromString<ShardStatusDto>(
"""{"enabled":true,"status":"disconnected","pluginConnected":false,"onlineCount":0}""",
)
assertFalse(dto.isOnline)
}
@Test fun champUpdateFrameDecodesWithKindAndExtras() {
// A live champ.update frame: has `kind`, `serial`, and category extras. The
// `kind` field is ignored (not on the DTO) and the extras decode.
val dto = json.decodeFromString<ChampDto>(
"""{"kind":"champ.update","serial":12345,"category":"champion","name":"Barracoon",
"status":"active","active":true,"level":10,"maxKills":250,"kills":120,
"bossUp":false,"map":"Felucca","x":5571,"y":1379,"z":0,"t":1721426400000}""",
)
assertEquals(12345L, dto.serial)
assertEquals("champion", dto.category)
assertEquals(120, dto.kills)
assertTrue(dto.active)
}
@Test fun guildFrameDecodesLeaderActor() {
val dto = json.decodeFromString<GuildDto>(
"""{"kind":"guild.update","id":7,"name":"Knights","abbr":"KNT","members":12,
"online":3,"alliance":"Light","leader":{"serial":1,"name":"Arthur","acct":"art"}}""",
)
assertEquals(7L, dto.id)
assertEquals("Arthur", dto.leader?.label)
assertEquals(12, dto.members)
}
@Test fun governorFrameDecodesNullGovernor() {
val dto = json.decodeFromString<GovernorDto>(
"""{"kind":"city.update","city":"Britain","governor":null,"electionPhase":"nominations"}""",
)
assertEquals("Britain", dto.city)
assertNull(dto.governor)
assertEquals("nominations", dto.electionPhase)
}
@Test fun presenceDecodesMaps() {
val dto = json.decodeFromString<PresenceDto>(
"""{"count":37,"byFacet":{"Felucca":10,"Trammel":27},"byRegion":{"Britain":5},"t":1}""",
)
assertEquals(37, dto.count)
assertEquals(27, dto.byFacet["Trammel"])
assertEquals(5, dto.byRegion["Britain"])
}
@Test fun houseDecodesPublicIdocShape() {
val dto = json.decodeFromString<HouseDto>(
"""{"serial":999,"name":"Tower","region":"Britain","map":"Felucca",
"x":1,"y":2,"z":3,"isIdoc":true}""",
)
assertEquals(999L, dto.serial)
assertTrue(dto.isIdoc)
}
@Test fun actorLabelFallsBackToAcctThenSomeone() {
assertEquals("bob", ActorDto(acct = "bob").label)
assertEquals("Someone", ActorDto().label)
}
}

View File

@@ -0,0 +1,52 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import org.junit.Assert.assertEquals
import org.junit.Test
/** Unit tests for the live board merge helper (PLAN.md §6.2). */
class LiveBoardTest {
private data class Row(val id: String, val name: String)
private fun board() = LiveBoard<Row> { it.id }
@Test fun seedReplacesContents() {
val b = board()
b.seed(listOf(Row("1", "a"), Row("2", "b")))
b.seed(listOf(Row("3", "c")))
assertEquals(listOf(Row("3", "c")), b.values())
}
@Test fun upsertInsertsThenUpdatesInPlace() {
val b = board()
b.seed(listOf(Row("1", "a")))
b.upsert(Row("2", "b"))
b.upsert(Row("1", "a2")) // update existing — no reorder, no duplicate
assertEquals(listOf(Row("1", "a2"), Row("2", "b")), b.values())
}
@Test fun removeDropsById() {
val b = board()
b.seed(listOf(Row("1", "a"), Row("2", "b")))
b.remove("1")
assertEquals(listOf(Row("2", "b")), b.values())
}
@Test fun removeMissingIsNoOp() {
val b = board()
b.seed(listOf(Row("1", "a")))
b.remove("nope")
assertEquals(listOf(Row("1", "a")), b.values())
}
@Test fun insertionOrderPreserved() {
val b = board()
b.upsert(Row("b", "1"))
b.upsert(Row("a", "2"))
b.upsert(Row("c", "3"))
assertEquals(listOf("b", "a", "c"), b.values().map { it.id })
}
}

View File

@@ -0,0 +1,81 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for the live-feed event descriptions (PLAN.md §6.2), mirroring the
* website's `lib/shardEvents.js`. Fields arrive as a [kotlinx.serialization.json.JsonObject]
* — the shape of a live SSE frame.
*/
class ShardEventTextTest {
private fun fields(literal: String) = Json.parseToJsonElement(literal).jsonObject
@Test fun playerDeathWithKiller() {
val text = ShardEventText.describe(
"player.death",
fields("""{"who":{"name":"Alice"},"killer":{"name":"a dragon"}}"""),
)
assertEquals("Alice was slain by a dragon", text)
}
@Test fun playerDeathWithoutKiller() {
val text = ShardEventText.describe("player.death", fields("""{"who":"Bob"}"""))
assertEquals("Bob was slain", text)
}
@Test fun economySupplyGroupsNumbers() {
val text = ShardEventText.describe(
"economy.supply",
fields("""{"gold":123456789,"accounts":1200}"""),
)
assertEquals("Gold supply: 123,456,789 across 1,200 accounts", text)
}
@Test fun houseDecayReadsToAndRegion() {
val text = ShardEventText.describe(
"house.decay",
fields("""{"name":"Keep","to":"IDOC","region":"Britain"}"""),
)
assertEquals("Keep is now IDOC — Britain", text)
}
@Test fun champUpdateActiveWithBoss() {
val text = ShardEventText.describe(
"champ.update",
fields("""{"name":"Rikktor","status":"active","bossUp":true,"boss":"Rikktor"}"""),
)
assertEquals("Rikktor: boss is up (Rikktor)", text)
}
@Test fun cityUpdateWithGovernor() {
val text = ShardEventText.describe(
"city.update",
fields("""{"city":"Trinsic","governor":{"name":"Dupre"}}"""),
)
assertEquals("Trinsic is governed by Dupre", text)
}
@Test fun presenceOnlineCount() {
assertEquals(
"58 players online",
ShardEventText.describe("presence.online", fields("""{"count":58}""")),
)
}
@Test fun unknownKindFallsBackToKind() {
assertEquals("some.weird.kind", ShardEventText.describe("some.weird.kind", fields("{}")))
}
@Test fun missingActorReadsAsSomeone() {
val text = ShardEventText.describe("mob.login", fields("{}"))
assertTrue(text.startsWith("Someone"))
}
}