feat(m2): public shard widgets + live SSE stream
All checks were successful
PR Checks / android-build (pull_request) Successful in 9m4s
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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user