feat(enums): 接入 /meta/enums 权威枚举表,删掉客户端硬编码映射

客户端此前自带 platform / mood / mode 三张硬编码标签表,会与服务端漂移——
实测已经漂了:客户端把 `web` 显示成「Web」,权威表里是「网页」。

- data:MetaApi(GET /api/v1/meta/enums)+ MetaDto + MetaRepository,
  经 ApiClientFactory 创建以复用既有 token / 401 处理
- domain/enums/EnumCatalog.kt:标签查找器,**不含任何硬编码兜底表**;
  不在表内的取值一律原样返回(api-client §1.1 的未知值回落红线);
  platformLabelOrNull 供 deviceId 前缀这类「认识/不认识要走不同回落」的调用方用;
  空 id 或空 label 一律不入表(否则角标会显示空白,或查空串凭空借到别人的标签)
- core/prefs/EnumPreferences.kt:last-good 快照,baseUrl+username 指纹闸门,
  换服务器/账号即作废,登出清空
- EnumCatalogViewModel + LocalEnumCatalog:在 CyreneRoot 注入(与 LocalAppPrefs 同套路),
  聊天角标与管理页共读同一份
- 删除 platformLabels / personaModeLabels / moodLabels;MessageSource 与 AdminFormat
  改为查表,并保留各自原有的原样回落

为什么不留硬编码兜底表:没有登录就看不到任何消息,而权威表在登录前后即会拉取,
所以 last-good 快照几乎总是存在;留着硬编码表等于把「漂移」重新引进来,而且是无声的——
旧标签和新鲜标签长得一模一样。

不转换的:trace kind/status、memory scope、thinking.mode、WS session_busy 的 mode 集合——
它们都不在 /meta/enums 内,且原本就已按原样回落。

测试:115 全绿(88 → +27:MetaDtoRealPayload 8 / EnumCatalog 7 / EnumPrefs 3 /
MessageSource 8,AdminFormatTest 改写并 +1),其中 /meta/enums 与登录响应一样
用真实抓取的响应体做夹具。
This commit is contained in:
AskaEth committed 2026-10-04 13:19:34 +08:00
1 parent 9f054aad28
commit 09a3222b7b
17 files changed
+693 -48

No files matched your search

@@ -0,0 +1,128 @@
package top.yeij.cyrene.data.api.dto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
import top.yeij.cyrene.domain.enums.EnumCatalog
/**
* 契约钉死测试:用的是**线上后端实际抓下来的** `GET /api/v1/meta/enums` 响应体,
* 逐字节照抄(含 `unknown`)。
*
* 解码器用 [ApiClientFactory.json] —— 与生产同一份 `Json` 配置,所以这里过不了的形状线上也过不了。
*
* 关键漂移点:客户端旧硬编码表把 `web` 写成 `Web`,权威表给的是 **`网页`**;
* 本测试把 6/5/9 三张表的 id 与 label 全钉死,任一侧漂移即失败。
*/
class MetaDtoRealPayloadTest {
private val json = ApiClientFactory.json
private val realEnums = """
{"data":{"platforms":[{"id":"web","label":"网页"},{"id":"kook","label":"KOOK"},{"id":"qq-obv11","label":"QQ"},{"id":"weixin-ilink","label":"微信"},{"id":"telegram","label":"Telegram"},{"id":"webhook","label":"Webhook"}],"moods":[{"id":"calm","label":"平静"},{"id":"happy","label":"开心"},{"id":"excited","label":"兴奋"},{"id":"sad","label":"低落"},{"id":"angry","label":"生气"}],"modes":[{"id":"chat","label":"对话"},{"id":"code","label":"代码"},{"id":"home","label":"家居"},{"id":"work","label":"工作"},{"id":"docs","label":"文档"},{"id":"voice","label":"语音"},{"id":"vision","label":"视觉"},{"id":"agents","label":"智能体"},{"id":"settings","label":"设置"}],"unknown":"display-raw"}}
""".trimIndent()
private fun decode(body: String): EnumTableDto =
json.decodeFromString(ApiEnvelope.serializer(EnumTableDto.serializer()), body).data!!
@Test
fun `real platforms table decodes with authoritative ids and labels`() {
val platforms = decode(realEnums).platforms
assertEquals(6, platforms.size)
assertEquals(
listOf("web", "kook", "qq-obv11", "weixin-ilink", "telegram", "webhook"),
platforms.map { it.id },
)
assertEquals(
listOf("网页", "KOOK", "QQ", "微信", "Telegram", "Webhook"),
platforms.map { it.label },
)
// 客户端旧硬编码表写的是 `Web`——这条断言就是那处漂移的钉子。
assertEquals("网页", platforms.first { it.id == "web" }.label)
}
@Test
fun `real moods table has five tracked moods`() {
val moods = decode(realEnums).moods
assertEquals(5, moods.size)
assertEquals(listOf("calm", "happy", "excited", "sad", "angry"), moods.map { it.id })
assertEquals(listOf("平静", "开心", "兴奋", "低落", "生气"), moods.map { it.label })
}
@Test
fun `real modes table has nine modes`() {
val modes = decode(realEnums).modes
assertEquals(9, modes.size)
assertEquals(
listOf("chat", "code", "home", "work", "docs", "voice", "vision", "agents", "settings"),
modes.map { it.id },
)
assertEquals(
listOf("对话", "代码", "家居", "工作", "文档", "语音", "视觉", "智能体", "设置"),
modes.map { it.label },
)
}
@Test
fun `real payload carries the machine readable raw fallback convention`() {
val table = decode(realEnums)
assertEquals("display-raw", table.unknown)
// 契约 id 一律小写:判定大小写敏感,客户端不得自行归一化。
assertTrue(
(table.platforms + table.moods + table.modes).all { it.id == it.id.lowercase() },
)
}
@Test
fun `catalog built from the real payload agrees with the backend table`() {
val catalog = EnumCatalog.of(decode(realEnums))
assertEquals("网页", catalog.platformLabel("web"))
assertEquals("微信", catalog.platformLabel("weixin-ilink"))
assertEquals("生气", catalog.moodLabel("angry"))
assertEquals("智能体", catalog.modeLabel("agents"))
// 服务端新增取值:非破坏性,客户端原样显示。
assertEquals("mastodon", catalog.platformLabel("mastodon"))
assertEquals("euphoric", catalog.moodLabel("euphoric"))
assertEquals("telepathy", catalog.modeLabel("telepathy"))
}
@Test
fun `partial payload still decodes and missing tables fall back to raw`() {
val table = decode("""{"data":{"platforms":[{"id":"web","label":"网页"}]}}""")
assertEquals(listOf(EnumItemDto("web", "网页")), table.platforms)
// 缺的三张表 / 约定用默认值兜住,而不是让整次解码失败。
assertEquals(emptyList<EnumItemDto>(), table.moods)
assertEquals(emptyList<EnumItemDto>(), table.modes)
assertEquals("", table.unknown)
val catalog = EnumCatalog.of(table)
assertEquals("网页", catalog.platformLabel("web"))
assertEquals("calm", catalog.moodLabel("calm"))
assertEquals("chat", catalog.modeLabel("chat"))
}
@Test
fun `explicit nulls in the payload are coerced to the defaults`() {
val table = decode("""{"data":{"platforms":null,"moods":[],"modes":[],"unknown":null}}""")
assertEquals(emptyList<EnumItemDto>(), table.platforms)
assertEquals("", table.unknown)
}
@Test
fun `item without a label decodes to an empty label, which the catalog treats as absent`() {
val table = decode("""{"data":{"platforms":[{"id":"future"}]}}""")
assertEquals(EnumItemDto("future", ""), table.platforms.single())
// label 缺失时不拿 id 充数、也不显示空角标:整项按「不在表内」处理,原样显示 id。
assertEquals("future", EnumCatalog.of(table).platformLabel("future"))
}
}