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:
1 parent
9f054aad28
commit
09a3222b7b
17 files changed
+693
-48
No files matched your search
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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<EnumCatalog> = _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()}"
|
||||
}
|
||||
@@ -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<Preferences> 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<Preferences> = context.applicationContext.enumDataStore
|
||||
|
||||
val data: Flow<EnumPrefs> = 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")
|
||||
}
|
||||
}
|
||||
@@ -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<ApiEnvelope<EnumTableDto>>
|
||||
}
|
||||
@@ -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<EnumItemDto> = emptyList(),
|
||||
val moods: List<EnumItemDto> = emptyList(),
|
||||
val modes: List<EnumItemDto> = emptyList(),
|
||||
val unknown: String = "",
|
||||
)
|
||||
@@ -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<EnumTableDto> = withContext(dispatcher) {
|
||||
apiCall { api.enums() }
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String, String>,
|
||||
private val moods: Map<String, String>,
|
||||
private val modes: Map<String, String>,
|
||||
) {
|
||||
|
||||
/** 表内 → 默认标签;不在表内 → 原始值。 */
|
||||
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<EnumItemDto>.toLabels(): Map<String, String> = mapNotNull { item ->
|
||||
item.takeIf { it.id.isNotBlank() && it.label.isNotBlank() }?.let { it.id to it.label }
|
||||
}.toMap()
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局注入枚举标签查找器,供聊天角标 / 管理页等深层组件读取(与 `LocalAppPrefs` 同套路)。 */
|
||||
val LocalEnumCatalog = staticCompositionLocalOf { EnumCatalog.Empty }
|
||||
@@ -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) }
|
||||
}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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>:<uid>,只取平台名做友好显示
|
||||
platformLabels[deviceId.substringBefore(':')]?.let { return it }
|
||||
return deviceId
|
||||
// IM 端 deviceId 形如 <平台名>:<配置id>:<uid>,只取平台名做友好显示;
|
||||
// 平台名不在表里(服务端新增渠道)则整串原样显示,不丢信息也不猜。
|
||||
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
|
||||
}
|
||||
@@ -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(""))
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user