From 09a3222b7b3ec47b58c311274de2210ef4a04407 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 4 Oct 2026 13:19:34 +0800 Subject: [PATCH] =?UTF-8?q?feat(enums):=20=E6=8E=A5=E5=85=A5=20/meta/enums?= =?UTF-8?q?=20=E6=9D=83=E5=A8=81=E6=9E=9A=E4=B8=BE=E8=A1=A8=EF=BC=8C?= =?UTF-8?q?=E5=88=A0=E6=8E=89=E5=AE=A2=E6=88=B7=E7=AB=AF=E7=A1=AC=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 客户端此前自带 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 与登录响应一样 用真实抓取的响应体做夹具。 --- .../top/yeij/cyrene/app/AppContainer.kt | 26 ++++ .../kotlin/top/yeij/cyrene/app/CyreneRoot.kt | 15 +- .../yeij/cyrene/app/EnumCatalogViewModel.kt | 53 ++++++++ .../yeij/cyrene/core/prefs/EnumPreferences.kt | 63 +++++++++ .../top/yeij/cyrene/data/api/MetaApi.kt | 16 +++ .../top/yeij/cyrene/data/api/dto/MetaDto.kt | 29 ++++ .../cyrene/data/repository/MetaRepository.kt | 22 +++ .../yeij/cyrene/domain/admin/AdminFormat.kt | 30 ++-- .../yeij/cyrene/domain/enums/EnumCatalog.kt | 63 +++++++++ .../cyrene/ui/admin/AdminPersonaScreen.kt | 11 +- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 6 +- .../top/yeij/cyrene/ui/chat/MessageSource.kt | 29 ++-- .../yeij/cyrene/core/prefs/EnumPrefsTest.kt | 38 ++++++ .../data/api/dto/MetaDtoRealPayloadTest.kt | 128 ++++++++++++++++++ .../cyrene/domain/admin/AdminFormatTest.kt | 32 ++++- .../cyrene/domain/enums/EnumCatalogTest.kt | 89 ++++++++++++ .../yeij/cyrene/ui/chat/MessageSourceTest.kt | 91 +++++++++++++ 17 files changed, 693 insertions(+), 48 deletions(-) create mode 100644 app/src/main/kotlin/top/yeij/cyrene/app/EnumCatalogViewModel.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/core/prefs/EnumPreferences.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/MetaApi.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MetaDto.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/data/repository/MetaRepository.kt create mode 100644 app/src/main/kotlin/top/yeij/cyrene/domain/enums/EnumCatalog.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/core/prefs/EnumPrefsTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/data/api/dto/MetaDtoRealPayloadTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/domain/enums/EnumCatalogTest.kt create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageSourceTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt index 687542a..526e5aa 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/AppContainer.kt @@ -4,12 +4,15 @@ import android.content.Context import top.yeij.cyrene.core.auth.AuthManager import top.yeij.cyrene.core.auth.SessionStore import top.yeij.cyrene.core.prefs.AppSettings +import top.yeij.cyrene.core.prefs.EnumPreferences import top.yeij.cyrene.core.prefs.SessionPreferences import top.yeij.cyrene.data.api.AdminApi import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.MetaApi import top.yeij.cyrene.data.api.SessionsApi import top.yeij.cyrene.data.repository.AdminRepository import top.yeij.cyrene.data.repository.MessageRepository +import top.yeij.cyrene.data.repository.MetaRepository import top.yeij.cyrene.data.repository.SessionRepository import top.yeij.cyrene.data.ws.ChatSocket @@ -18,6 +21,7 @@ class AppContainer(context: Context) { val sessionStore: SessionStore = SessionStore(context.applicationContext) val sessionPreferences: SessionPreferences = SessionPreferences(context.applicationContext) + val enumPreferences: EnumPreferences = EnumPreferences(context.applicationContext) val appSettings: AppSettings = AppSettings(context.applicationContext) val authManager: AuthManager = AuthManager(sessionStore) @@ -67,6 +71,28 @@ class AppContainer(context: Context) { val adminRepository: AdminRepository get() = AdminRepository(adminApi) + private var cachedMetaApi: MetaApi? = null + private var cachedMetaBaseUrl: String? = null + + /** 枚举权威表客户端;同样与 [sessionsApi] 同源(同一 baseUrl、同一 token/401 处理)。 */ + val metaApi: MetaApi + get() { + val baseUrl = authManager.currentBaseUrl + val cached = cachedMetaApi + if (cached != null && cachedMetaBaseUrl == baseUrl) return cached + val created = ApiClientFactory.create( + baseUrl = baseUrl, + service = MetaApi::class.java, + tokenProvider = { sessionStore.load().token }, + onUnauthorized = { authManager.onSessionExpired() }, + ) + cachedMetaBaseUrl = baseUrl + cachedMetaApi = created + return created + } + + val metaRepository: MetaRepository get() = MetaRepository(metaApi) + fun createChatSocket(): ChatSocket = ChatSocket( baseUrlProvider = { authManager.currentBaseUrl }, tokenProvider = { sessionStore.load().token }, diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneRoot.kt b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneRoot.kt index 84be216..68525da 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/app/CyreneRoot.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/app/CyreneRoot.kt @@ -11,19 +11,27 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import top.yeij.cyrene.core.prefs.AppPrefs import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yeij.cyrene.core.prefs.ThemeMode import top.yeij.cyrene.core.theme.CyreneTheme +import top.yeij.cyrene.domain.enums.LocalEnumCatalog import top.yukonga.miuix.kmp.theme.ColorSchemeMode /** - * 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] 下发给渲染层, + * 应用根:读 App 偏好 → 决定 Miuix 主题模式 → 通过 [LocalAppPrefs] / [LocalEnumCatalog] 下发给渲染层, * 同时把系统栏图标明暗跟着主题走(否则浅色模式下白字白底看不见)。 */ @Composable fun CyreneRoot(container: AppContainer) { val prefs by container.appSettings.data.collectAsState(initial = AppPrefs()) + val enumCatalogViewModel: EnumCatalogViewModel = viewModel( + factory = viewModelFactory { initializer { EnumCatalogViewModel(container) } }, + ) + val catalog by enumCatalogViewModel.catalog.collectAsState() val darkTheme = when (prefs.themeMode) { ThemeMode.System -> isSystemInDarkTheme() ThemeMode.Light -> false @@ -41,7 +49,10 @@ fun CyreneRoot(container: AppContainer) { } } - CompositionLocalProvider(LocalAppPrefs provides prefs) { + CompositionLocalProvider( + LocalAppPrefs provides prefs, + LocalEnumCatalog provides catalog, + ) { CyreneTheme(mode = prefs.themeMode.toColorSchemeMode()) { CyreneApp(container) } diff --git a/app/src/main/kotlin/top/yeij/cyrene/app/EnumCatalogViewModel.kt b/app/src/main/kotlin/top/yeij/cyrene/app/EnumCatalogViewModel.kt new file mode 100644 index 0000000..0b25bd8 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/app/EnumCatalogViewModel.kt @@ -0,0 +1,53 @@ +package top.yeij.cyrene.app + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import top.yeij.cyrene.core.auth.SessionState +import top.yeij.cyrene.domain.enums.EnumCatalog + +/** + * 枚举权威表(api-client §1.1)的应用级持有者:先用本地快照渲染,再拉一次权威表。 + * + * - 登录 / 静默重登后拉取;**拉取失败保持现状**(快照或原样 id),绝不因此清空角标; + * - 快照按 `baseUrl + username` 指纹认领:换服务器/账号即作废; + * - 登出时落盘快照与内存标签一并清掉(此时界面只剩登录页,不会闪空角标)。 + */ +class EnumCatalogViewModel(private val container: AppContainer) : ViewModel() { + + private val _catalog = MutableStateFlow(EnumCatalog.Empty) + val catalog: StateFlow = _catalog.asStateFlow() + + init { + viewModelScope.launch { + container.authManager.state.collect { state -> + if (state == SessionState.LoggedOut) { + container.enumPreferences.clear() + _catalog.value = EnumCatalog.Empty + return@collect + } + val fingerprint = fingerprint() + // 先用快照渲染(没有就原样显示 id),拉取不阻塞首帧 + val snapshot = container.enumPreferences.data.first() + _catalog.value = EnumCatalog.of(snapshot.tableFor(fingerprint)) + if (state is SessionState.LoggedIn) refresh(fingerprint) + } + } + } + + private fun refresh(fingerprint: String) { + viewModelScope.launch { + container.metaRepository.enums().onSuccess { table -> + container.enumPreferences.save(table, fingerprint) + _catalog.value = EnumCatalog.of(table) + } + } + } + + private fun fingerprint(): String = + "${container.authManager.currentBaseUrl}|${container.sessionStore.load().username.orEmpty()}" +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/core/prefs/EnumPreferences.kt b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/EnumPreferences.kt new file mode 100644 index 0000000..bc46e80 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/core/prefs/EnumPreferences.kt @@ -0,0 +1,63 @@ +package top.yeij.cyrene.core.prefs + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import top.yeij.cyrene.data.api.ApiClientFactory +import top.yeij.cyrene.data.api.dto.EnumTableDto + +private val Context.enumDataStore: DataStore by preferencesDataStore("cyrene_enums") + +/** + * 枚举权威表的本地回退快照(§1.1)。 + * + * @param table 上次成功拉取的权威表;拉取失败时先用它渲染(绝不因此清空角标) + * @param fingerprint `baseUrl + username`:换服务器/账号后快照作废 + */ +data class EnumPrefs( + val table: EnumTableDto? = null, + val fingerprint: String = "", +) { + + /** 指纹匹配才认这份快照——绝不把别的服务器/账号的标签拿来用。 */ + fun tableFor(fingerprint: String): EnumTableDto? = table?.takeIf { this.fingerprint == fingerprint } +} + +class EnumPreferences(context: Context) { + + private val json = ApiClientFactory.json + + private val store: DataStore = context.applicationContext.enumDataStore + + val data: Flow = store.data.map { prefs -> + EnumPrefs( + table = prefs[KEY_TABLE].decodeTable(), + fingerprint = prefs[KEY_FINGERPRINT].orEmpty(), + ) + } + + suspend fun save(table: EnumTableDto, fingerprint: String) { + store.edit { prefs -> + prefs[KEY_TABLE] = json.encodeToString(EnumTableDto.serializer(), table) + prefs[KEY_FINGERPRINT] = fingerprint + } + } + + /** 登出 / 切换服务器时清空。 */ + suspend fun clear() { + store.edit { it.clear() } + } + + private fun String?.decodeTable(): EnumTableDto? = this + ?.let { runCatching { json.decodeFromString(EnumTableDto.serializer(), it) }.getOrNull() } + + private companion object { + val KEY_TABLE = stringPreferencesKey("enum_table") + val KEY_FINGERPRINT = stringPreferencesKey("enum_fingerprint") + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/MetaApi.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/MetaApi.kt new file mode 100644 index 0000000..d4c5a88 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/MetaApi.kt @@ -0,0 +1,16 @@ +package top.yeij.cyrene.data.api + +import top.yeij.cyrene.data.api.dto.EnumTableDto +import retrofit2.Response +import retrofit2.http.GET + +/** + * 共享枚举权威表端点(api-client §1.1 / api-reference §3.7)。 + * 任意已认证角色可读(与 `GET /sessions` 同级),无 token 回 `401`; + * 纯内存投影,没有其它错误码。按本工程惯例单独成接口。 + */ +interface MetaApi { + + @GET("api/v1/meta/enums") + suspend fun enums(): Response> +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MetaDto.kt b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MetaDto.kt new file mode 100644 index 0000000..7d64550 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/api/dto/MetaDto.kt @@ -0,0 +1,29 @@ +package top.yeij.cyrene.data.api.dto + +import kotlinx.serialization.Serializable + +/** + * `GET /api/v1/meta/enums` 表内一项(api-client §1.1)。 + * [id] 是**唯一可比对**的值(契约 id 为小写、大小写敏感);[label] 只是显示默认值, + * 自带 i18n 的客户端可以覆盖。 + */ +@Serializable +data class EnumItemDto( + val id: String = "", + val label: String = "", +) + +/** + * `platform` / `mood` / `mode` 的共享枚举权威表——客户端**不必硬编码**即可拿到稳定映射。 + * + * 字段全部带默认值:只下发部分表(或 `null`)时仍能解码,缺失的那一类按「表内无此项」 + * 走原样回落,而不是让整次解码失败。 + * [unknown] 是机器可读的未知值回落约定,当前恒为 `"display-raw"`(原样显示,见 §1.1 红线)。 + */ +@Serializable +data class EnumTableDto( + val platforms: List = emptyList(), + val moods: List = emptyList(), + val modes: List = emptyList(), + val unknown: String = "", +) diff --git a/app/src/main/kotlin/top/yeij/cyrene/data/repository/MetaRepository.kt b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MetaRepository.kt new file mode 100644 index 0000000..4ba1975 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/data/repository/MetaRepository.kt @@ -0,0 +1,22 @@ +package top.yeij.cyrene.data.repository + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import top.yeij.cyrene.data.api.MetaApi +import top.yeij.cyrene.data.api.apiCall +import top.yeij.cyrene.data.api.dto.EnumTableDto + +/** + * 枚举权威表取数层(api-client §1.1)。 + * 仓库不持有持久化状态:调用方失败时保留上次快照(见 [top.yeij.cyrene.core.prefs.EnumPreferences])。 + */ +class MetaRepository( + private val api: MetaApi, + private val dispatcher: CoroutineDispatcher = Dispatchers.IO, +) { + + suspend fun enums(): Result = withContext(dispatcher) { + apiCall { api.enums() } + } +} diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt index 47788b0..839ac73 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/admin/AdminFormat.kt @@ -2,6 +2,7 @@ package top.yeij.cyrene.domain.admin import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import top.yeij.cyrene.domain.enums.EnumCatalog import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.parse import java.math.BigDecimal @@ -72,32 +73,12 @@ private val traceStatusLabels = mapOf( "error" to "失败", ) -private val personaModeLabels = mapOf( - "chat" to "对话", - "code" to "代码", - "home" to "家居", - "work" to "工作", - "docs" to "文档", - "voice" to "语音", - "vision" to "视觉", - "agents" to "智能体", - "settings" to "设置", -) - private val memoryScopeLabels = mapOf( "user" to "用户", "persona" to "人格", "project" to "项目", ) -private val moodLabels = mapOf( - "calm" to "平静", - "happy" to "开心", - "excited" to "兴奋", - "sad" to "低落", - "angry" to "生气", -) - /** * 未知取值回落约定(api-client §1 红线):不在表中的取值**原样返回**—— * 不丢弃、不报错、不替换占位符。服务端新增取值属非破坏性变更。 @@ -106,11 +87,16 @@ fun traceKindLabel(raw: String): String = traceKindLabels[raw] ?: raw fun traceStatusLabel(raw: String): String = traceStatusLabels[raw] ?: raw -fun personaModeLabel(raw: String): String = personaModeLabels[raw] ?: raw +/** + * `mode` / `mood` 的标签来自 `/meta/enums` 的权威表([catalog]),客户端不再硬编码—— + * 表里没有的取值同样原样返回([EnumCatalog] 负责该回落)。 + * `trace` / `memory` 那几类**不在**该端点的表内,各自保留本地映射(见上)。 + */ +fun personaModeLabel(raw: String, catalog: EnumCatalog): String = catalog.modeLabel(raw) fun memoryScopeLabel(raw: String): String = memoryScopeLabels[raw] ?: raw -fun moodLabel(raw: String): String = moodLabels[raw] ?: raw +fun moodLabel(raw: String, catalog: EnumCatalog): String = catalog.moodLabel(raw) /** 三态展示:null(未配置)与 true/false 是**三种不同**状态,不能合并。 */ fun triStateLabel(value: Boolean?): String = when (value) { diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/enums/EnumCatalog.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/enums/EnumCatalog.kt new file mode 100644 index 0000000..37a1951 --- /dev/null +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/enums/EnumCatalog.kt @@ -0,0 +1,63 @@ +package top.yeij.cyrene.domain.enums + +import androidx.compose.runtime.staticCompositionLocalOf +import top.yeij.cyrene.data.api.dto.EnumItemDto +import top.yeij.cyrene.data.api.dto.EnumTableDto + +/** + * 枚举标签查找器(api-client §1.1):`/meta/enums` 抓到什么就查什么。 + * + * **未知取值回落(红线)**:不在表内的取值一律**原样返回**——不丢弃、不报错、不替换占位符、 + * 不猜测;服务端新增取值属非破坏性变更,不得因此清空角标或中断渲染。 + * + * 因此这里**没有任何硬编码兜底表**:宁可显示原始 id,也不能让客户端自建的表与服务端漂移。 + * 还没拿到表(未登录 / 首次拉取失败且无快照)时用 [Empty],一切原样显示。 + * + * 判定只比对 `id`(大小写敏感,契约 id 为小写);`label` 仅作显示默认值。 + */ +class EnumCatalog private constructor( + private val platforms: Map, + private val moods: Map, + private val modes: Map, +) { + + /** 表内 → 默认标签;不在表内 → 原始值。 */ + fun platformLabel(id: String): String = platforms[id] ?: id + + fun moodLabel(id: String): String = moods[id] ?: id + + fun modeLabel(id: String): String = modes[id] ?: id + + /** + * 表内 → 默认标签;不在表内 → `null`。 + * 给「认识」与「不认识」需要走**不同**回落的调用方用(如 `deviceId` 前缀: + * 认识的平台名取友好标签,不认识的原样显示整串 `deviceId`,而不是只留前缀)。 + */ + fun platformLabelOrNull(id: String): String? = platforms[id] + + companion object { + /** 尚无权威表:一切原样显示。 */ + val Empty = EnumCatalog(emptyMap(), emptyMap(), emptyMap()) + + fun of(table: EnumTableDto?): EnumCatalog = if (table == null) { + Empty + } else { + EnumCatalog( + platforms = table.platforms.toLabels(), + moods = table.moods.toLabels(), + modes = table.modes.toLabels(), + ) + } + + /** + * 空 id 不入表:否则查空串会拿到别人的标签,等于凭空造出一个标签。 + * 空 label 同样不入表:查得到却没有标签只会把角标显示成空白,不如原样显示 id。 + */ + private fun List.toLabels(): Map = mapNotNull { item -> + item.takeIf { it.id.isNotBlank() && it.label.isNotBlank() }?.let { it.id to it.label } + }.toMap() + } +} + +/** 全局注入枚举标签查找器,供聊天角标 / 管理页等深层组件读取(与 `LocalAppPrefs` 同套路)。 */ +val LocalEnumCatalog = staticCompositionLocalOf { EnumCatalog.Empty } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminPersonaScreen.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminPersonaScreen.kt index 769a1e0..c1a138a 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminPersonaScreen.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/admin/AdminPersonaScreen.kt @@ -25,6 +25,7 @@ import top.yeij.cyrene.domain.admin.moodLabel import top.yeij.cyrene.domain.admin.orDash import top.yeij.cyrene.domain.admin.personaModeLabel import top.yeij.cyrene.domain.admin.triStateLabel +import top.yeij.cyrene.domain.enums.LocalEnumCatalog import top.yeij.cyrene.ui.common.PreferenceCard import top.yukonga.miuix.kmp.basic.Text import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -62,6 +63,7 @@ fun AdminPersonaScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> @Composable private fun PersonaCard(persona: PersonaSummaryDto) { + val catalog = LocalEnumCatalog.current PreferenceCard { Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { @@ -76,11 +78,14 @@ private fun PersonaCard(persona: PersonaSummaryDto) { } PersonaDetail("id", persona.id) PersonaDetail("character", persona.character.ifBlank { "-" }) - // mode 是开放集合:未知取值原样显示。 - PersonaDetail("mode", personaModeLabel(persona.mode).ifBlank { "-" }) + // mode 是开放集合:权威表里没有的取值原样显示。 + PersonaDetail("mode", personaModeLabel(persona.mode, catalog).ifBlank { "-" }) PersonaDetail("记忆域", persona.memoryNamespace.ifBlank { "-" }) // mood 为空表示「该角色暂无被跟踪的情绪」,不伪造默认值。 - PersonaDetail("情绪", persona.mood?.takeIf { it.isNotBlank() }?.let(::moodLabel) ?: "暂无") + PersonaDetail( + "情绪", + persona.mood?.takeIf { it.isNotBlank() }?.let { moodLabel(it, catalog) } ?: "暂无", + ) PersonaDetail("思考", thinkingLabel(persona.thinking)) persona.avatar?.let { PersonaDetail("avatar", it) } } diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index 54b5355..ef67472 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import top.yeij.cyrene.core.prefs.LocalAppPrefs import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.domain.enums.LocalEnumCatalog import top.yeij.cyrene.domain.parse.ContentPart import top.yeij.cyrene.domain.parse.ParsedBubble import top.yeij.cyrene.domain.parse.parse @@ -43,13 +44,16 @@ private const val CURSOR = "▍" @Composable fun MessageBubble(message: HistoryMessageDto) { val isUser = message.role == "user" + // 这里的 `chat` 是 **mode id**,与 `/meta/enums` 的 modes 表里那一条同源但用途不同: + // 表只能给「id → 标签」,说不出哪个 mode 是「按 markup 切气泡的聊天式渲染」, + // 所以这条字面量刻意保留为客户端渲染契约,不表驱动。 val isChat = message.mode.isNullOrBlank() || message.mode == "chat" val bubbles = remember(message.content, isChat) { val parsed = if (isChat) parse(message.content).filterNot { it.isEmpty } else emptyList() parsed.ifEmpty { listOf(plainBubble(message.content)) } } - MessageGroup(isUser = isUser, footer = messageSourceLabel(message)) { + MessageGroup(isUser = isUser, footer = messageSourceLabel(message, LocalEnumCatalog.current)) { message.thinking?.takeIf { it.isNotBlank() }?.let { ThinkingCard(it) } message.image?.takeIf { it.isNotBlank() }?.let { ChatImage(it) } bubbles.forEachIndexed { index, bubble -> diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt index d8d902a..ce3c07a 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageSource.kt @@ -1,42 +1,39 @@ package top.yeij.cyrene.ui.chat import top.yeij.cyrene.data.api.dto.HistoryMessageDto - -private val platformLabels = mapOf( - "web" to "Web", - "kook" to "KOOK", - "weixin-ilink" to "微信", - "qq-obv11" to "QQ", - "telegram" to "Telegram", - "webhook" to "Webhook", -) +import top.yeij.cyrene.domain.enums.EnumCatalog /** * 来源角标(S1.3):每条消息默认显示来源。 * - 主人发的 → 「来自 X」(从哪一端发出) * - 昔涟发的 → 「发送至 X」(回到哪一端) * 优先用 `deviceId` 区分具体端,再回落到 `platform`;两者都没有则不显示。 + * + * 平台名到标签的映射来自 `/meta/enums` 的权威表([catalog]),**不含任何硬编码表**: + * 表里没有的取值原样显示(红线),未登录 / 尚无快照时全部原样。 */ -fun messageSourceLabel(message: HistoryMessageDto): String? { - val name = sourceName(message) ?: return null +fun messageSourceLabel(message: HistoryMessageDto, catalog: EnumCatalog): String? { + val name = sourceName(message, catalog) ?: return null val verb = if (message.role == "assistant") "发送至" else "来自" return "$verb $name" } -private fun sourceName(message: HistoryMessageDto): String? { +private fun sourceName(message: HistoryMessageDto, catalog: EnumCatalog): String? { val deviceId = message.deviceId if (!deviceId.isNullOrBlank()) { + // 下面两条是 device 粒度、与平台表无关的端标识,不参与权威表映射。 if (deviceId.startsWith("voiceassist")) return "安卓" if (deviceId.startsWith("web:")) { val browser = deviceId.removePrefix("web:").substringBefore(':') return "Web($browser)" } - // IM 端 deviceId 形如 <平台名>:<配置id>:,只取平台名做友好显示 - platformLabels[deviceId.substringBefore(':')]?.let { return it } - return deviceId + // IM 端 deviceId 形如 <平台名>:<配置id>:,只取平台名做友好显示; + // 平台名不在表里(服务端新增渠道)则整串原样显示,不丢信息也不猜。 + val platform = deviceId.substringBefore(':') + return catalog.platformLabelOrNull(platform) ?: deviceId } val platform = message.platform - if (!platform.isNullOrBlank()) return platformLabels[platform] ?: platform + if (!platform.isNullOrBlank()) return catalog.platformLabel(platform) return null } diff --git a/app/src/test/kotlin/top/yeij/cyrene/core/prefs/EnumPrefsTest.kt b/app/src/test/kotlin/top/yeij/cyrene/core/prefs/EnumPrefsTest.kt new file mode 100644 index 0000000..c53105e --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/core/prefs/EnumPrefsTest.kt @@ -0,0 +1,38 @@ +package top.yeij.cyrene.core.prefs + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import top.yeij.cyrene.data.api.dto.EnumItemDto +import top.yeij.cyrene.data.api.dto.EnumTableDto + +/** + * 快照的指纹闸门:`baseUrl + username` 变了,上一份权威表就作废—— + * 绝不把别的服务器/账号的标签拿来用(宁可原样显示 id)。 + */ +class EnumPrefsTest { + + private val table = EnumTableDto(platforms = listOf(EnumItemDto("web", "网页"))) + + private val fingerprint = "http://host:10942/|testuser" + + @Test + fun `a matching fingerprint yields the snapshot`() { + assertEquals(table, EnumPrefs(table, fingerprint).tableFor(fingerprint)) + } + + @Test + fun `another account or another server invalidates the snapshot`() { + val prefs = EnumPrefs(table, fingerprint) + + assertNull(prefs.tableFor("http://host:10942/|someone-else")) + assertNull(prefs.tableFor("http://other-host:10942/|testuser")) + assertNull(prefs.tableFor("|")) + } + + @Test + fun `no snapshot yet stays null even for the matching fingerprint`() { + assertNull(EnumPrefs(table = null, fingerprint = fingerprint).tableFor(fingerprint)) + assertNull(EnumPrefs().tableFor("")) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/MetaDtoRealPayloadTest.kt b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/MetaDtoRealPayloadTest.kt new file mode 100644 index 0000000..4b0db5b --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/data/api/dto/MetaDtoRealPayloadTest.kt @@ -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(), table.moods) + assertEquals(emptyList(), 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(), 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")) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt index ecc40d9..2800ba8 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/admin/AdminFormatTest.kt @@ -6,6 +6,9 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test +import top.yeij.cyrene.data.api.dto.EnumItemDto +import top.yeij.cyrene.data.api.dto.EnumTableDto +import top.yeij.cyrene.domain.enums.EnumCatalog import top.yeij.cyrene.domain.parse.ContentPart /** @@ -18,6 +21,16 @@ class AdminFormatTest { private val timestamp = Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}""") + /** `/meta/enums` 的权威表由调用方注入:这里只登记各表的一部分,用来验证「查表」而非「本地表」。 */ + private val enumCatalog = EnumCatalog.of( + EnumTableDto( + platforms = listOf(EnumItemDto("web", "网页")), + moods = listOf(EnumItemDto("calm", "平静")), + modes = listOf(EnumItemDto("chat", "对话")), + unknown = "display-raw", + ), + ) + @Test fun `offset and zulu spellings of the same instant agree`() { assertEquals( @@ -91,12 +104,23 @@ class AdminFormatTest { assertEquals("quantum_hop", traceKindLabel("quantum_hop")) assertEquals("成功", traceStatusLabel("ok")) assertEquals("degraded", traceStatusLabel("degraded")) - assertEquals("对话", personaModeLabel("chat")) - assertEquals("telepathy", personaModeLabel("telepathy")) + assertEquals("对话", personaModeLabel("chat", enumCatalog)) + assertEquals("telepathy", personaModeLabel("telepathy", enumCatalog)) assertEquals("人格", memoryScopeLabel("persona")) assertEquals("shared", memoryScopeLabel("shared")) - assertEquals("平静", moodLabel("calm")) - assertEquals("euphoric", moodLabel("euphoric")) + assertEquals("平静", moodLabel("calm", enumCatalog)) + assertEquals("euphoric", moodLabel("euphoric", enumCatalog)) + } + + @Test + fun `mode and mood are looked up in the injected table, not in a client-side copy`() { + // 注入表里只有 chat / calm:同为真实取值的 code / happy 没有表项, + // 因此必须原样显示——本地硬编码表会翻成「代码」「开心」,这条断言就是那处漂移的钉子。 + assertEquals("code", personaModeLabel("code", enumCatalog)) + assertEquals("happy", moodLabel("happy", enumCatalog)) + // 没有表(未登录 / 尚无快照)时同样原样显示,不回落任何客户端硬编码。 + assertEquals("chat", personaModeLabel("chat", EnumCatalog.Empty)) + assertEquals("calm", moodLabel("calm", EnumCatalog.Empty)) } @Test diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/enums/EnumCatalogTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/enums/EnumCatalogTest.kt new file mode 100644 index 0000000..5d3448d --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/enums/EnumCatalogTest.kt @@ -0,0 +1,89 @@ +package top.yeij.cyrene.domain.enums + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import top.yeij.cyrene.data.api.dto.EnumItemDto +import top.yeij.cyrene.data.api.dto.EnumTableDto + +/** + * 标签查找的未知值回落(api-client §1.1 红线):表里有就翻译,表里没有就**原样返回**—— + * 不丢弃、不报错、不替换占位符、不猜测。 + */ +class EnumCatalogTest { + + private val catalog = EnumCatalog.of( + EnumTableDto( + platforms = listOf(EnumItemDto("web", "网页"), EnumItemDto("telegram", "Telegram")), + moods = listOf(EnumItemDto("calm", "平静")), + modes = listOf(EnumItemDto("chat", "对话")), + unknown = "display-raw", + ), + ) + + @Test + fun `known ids resolve to the authoritative labels`() { + assertEquals("网页", catalog.platformLabel("web")) + assertEquals("Telegram", catalog.platformLabel("telegram")) + assertEquals("平静", catalog.moodLabel("calm")) + assertEquals("对话", catalog.modeLabel("chat")) + } + + @Test + fun `unknown ids are returned verbatim, never dropped or replaced`() { + assertEquals("mastodon", catalog.platformLabel("mastodon")) + assertEquals("euphoric", catalog.moodLabel("euphoric")) + assertEquals("telepathy", catalog.modeLabel("telepathy")) + // 判定只比对 id 且大小写敏感:`WEB` 不是表里的 `web`,不得归一化后当命中。 + assertEquals("WEB", catalog.platformLabel("WEB")) + } + + @Test + fun `a blank value is not looked up and stays blank`() { + assertEquals("", catalog.platformLabel("")) + assertEquals("", catalog.moodLabel("")) + assertEquals("", catalog.modeLabel("")) + } + + @Test + fun `empty catalog degrades everything to raw ids`() { + assertEquals("web", EnumCatalog.Empty.platformLabel("web")) + assertEquals("calm", EnumCatalog.Empty.moodLabel("calm")) + assertEquals("chat", EnumCatalog.Empty.modeLabel("chat")) + assertEquals("web", EnumCatalog.of(null).platformLabel("web")) + } + + @Test + fun `tables not present in a partial payload fall back to raw ids`() { + val platformsOnly = EnumCatalog.of(EnumTableDto(platforms = listOf(EnumItemDto("web", "网页")))) + + assertEquals("网页", platformsOnly.platformLabel("web")) + assertEquals("calm", platformsOnly.moodLabel("calm")) + assertEquals("chat", platformsOnly.modeLabel("chat")) + } + + @Test + fun `items with a blank id or blank label are not registered`() { + val table = EnumCatalog.of( + EnumTableDto( + platforms = listOf( + EnumItemDto("", "空 id"), + EnumItemDto("web", ""), + EnumItemDto("kook", "KOOK"), + ), + ), + ) + + // 空 id 不得让空查询命中;空 label 不得把角标显示成空白。 + assertEquals("", table.platformLabel("")) + assertEquals("web", table.platformLabel("web")) + assertEquals("KOOK", table.platformLabel("kook")) + } + + @Test + fun `platformLabelOrNull separates known from unknown without substituting`() { + assertEquals("网页", catalog.platformLabelOrNull("web")) + assertNull(catalog.platformLabelOrNull("mastodon")) + assertNull(EnumCatalog.Empty.platformLabelOrNull("web")) + } +} diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageSourceTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageSourceTest.kt new file mode 100644 index 0000000..c17cab5 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageSourceTest.kt @@ -0,0 +1,91 @@ +package top.yeij.cyrene.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import top.yeij.cyrene.data.api.dto.EnumItemDto +import top.yeij.cyrene.data.api.dto.EnumTableDto +import top.yeij.cyrene.data.api.dto.HistoryMessageDto +import top.yeij.cyrene.domain.enums.EnumCatalog + +/** + * 来源角标的两种回落(S1.3): + * 平台名查 `/meta/enums` 权威表(查不到原样显示),`deviceId` 的端粒度启发式与平台表无关。 + */ +class MessageSourceTest { + + private val catalog = EnumCatalog.of( + EnumTableDto( + platforms = listOf( + EnumItemDto("web", "网页"), + EnumItemDto("kook", "KOOK"), + EnumItemDto("telegram", "Telegram"), + ), + ), + ) + + private fun message( + role: String = "user", + platform: String? = null, + deviceId: String? = null, + ) = HistoryMessageDto( + id = "m1", + sessionId = "web", + role = role, + platform = platform, + deviceId = deviceId, + createdAt = 1_791_088_571_785L, + ) + + @Test + fun `platform label comes from the authoritative table`() { + // 权威表给 `web` 的标签是「网页」——旧硬编码表写的是 `Web`,此处即那处漂移的回归钉子。 + assertEquals("来自 网页", messageSourceLabel(message(platform = "web"), catalog)) + assertEquals("来自 KOOK", messageSourceLabel(message(platform = "kook"), catalog)) + assertEquals("发送至 Telegram", messageSourceLabel(message(role = "assistant", platform = "telegram"), catalog)) + } + + @Test + fun `unknown platform is displayed verbatim`() { + assertEquals("来自 mastodon", messageSourceLabel(message(platform = "mastodon"), catalog)) + } + + @Test + fun `without a table every platform is displayed verbatim rather than dropped`() { + assertEquals("来自 web", messageSourceLabel(message(platform = "web"), EnumCatalog.Empty)) + assertEquals("来自 kook", messageSourceLabel(message(platform = "kook"), EnumCatalog.Empty)) + } + + @Test + fun `device heuristics stay independent of the platform table`() { + assertEquals("来自 安卓", messageSourceLabel(message(deviceId = "voiceassist"), catalog)) + assertEquals("来自 Web(chrome)", messageSourceLabel(message(deviceId = "web:chrome:abc"), catalog)) + // deviceId 优先于 platform:两者都在时按 device 显示。 + assertEquals("来自 安卓", messageSourceLabel(message(platform = "web", deviceId = "voiceassist"), catalog)) + } + + @Test + fun `known platform prefix of an IM device id resolves to the table label`() { + assertEquals("来自 KOOK", messageSourceLabel(message(deviceId = "kook:bot1:uid9"), catalog)) + } + + @Test + fun `unknown platform prefix of an IM device id keeps the whole raw device id`() { + assertEquals( + "来自 matrix:acc1:uid9", + messageSourceLabel(message(deviceId = "matrix:acc1:uid9"), catalog), + ) + } + + @Test + fun `no source at all yields no badge`() { + assertNull(messageSourceLabel(message(), catalog)) + assertNull(messageSourceLabel(message(platform = " "), catalog)) + assertNull(messageSourceLabel(message(deviceId = ""), catalog)) + } + + @Test + fun `blank device id falls through to the platform`() { + assertEquals("来自 网页", messageSourceLabel(message(platform = "web", deviceId = " "), catalog)) + } +}