2 Commits
Author SHA1 Message Date
AskaEth 9f054aad28 fix(admin): 后台思考 conclusion 的 <p>/<action> 标记被原样显示
现象:后台思考页把服务端 conclusion 里的 Web 端标记当纯文本打印,真机截图里
能看到 `<p></p><p></p><p>亲爱的…</p>` 和 `<action>轻轻戳了戳你</action>` 这样的
原始标记,部分记录开头还有一串空 `<p></p>`。

为什么:backthink 的 conclusion 与聊天消息是同一套标记,而 AdminBackthinkScreen
只是把它拼进 body 字符串,从未解析。

改法:复用项目已有的 domain/parse/ChatContentParser.parse()(不新写正则),
在 domain/admin/AdminFormat.kt 加纯函数 conclusionParagraphs()——按
head → 正文 → tail 产出段落、丢弃无有效片段的空段落(这才是消掉空 <p></p> 的关键);
UI 侧新增 AdminRichTextCard,Text 正常渲染,Action/Narration 走聊天页同一套
灰斜体旁白样式(对齐 MessageBubble.AnnotationLine 的既有约定)。
AdminTextCard 行为不变(表头抽成共享的 AdminCardHeader),已回访记忆页确认无回归。

测试:新增 7 例(AdminFormatTest 13→20,用真实抓取的 conclusion 做夹具,并断言
渲染模型里不残留 <p>/</p>/<action>/< 或 > 任何一项),全套 88 例全绿。
2026-10-04 13:02:09 +08:00
AskaEth acff289887 test(admin): 用真实后端响应锁定管理端 DTO 契约
原先 AdminDtoDecodingTest 用的是手写的合成 JSON,等于拿「我以为的形状」自证,
证明不了真实契约。新增 AdminDtoRealPayloadTest,把 5 个管理端点的真实响应体
(登录后从本机 10942 实抓)过一遍生产用的 Json 实例。

顺带把真实数据里几个容易翻车的形状钉死:
- trace.durationNs 必须是 Long(真实 ~1.17e9 ns,超过 Int 上限就炸)——
  真实值恰好塞得进 Int,所以额外用一例 3e9 的边界值才真正证明它是 Long
- 根 span 没有 parentId 键 → null
- 未配置人格的 thinking 键完全不出现 → null(不得变成全零对象)
- persona 的 mode / memoryNamespace 是空串,而非缺键
- backthink.id 是 JSON 字符串("487"),at 是数字(Unix 毫秒)
- props / events 同一个 map 里混着数字与字符串

为什么:C-2 的 DTO 此前只经「编译通过」这一层,从未被真实响应验证过。
真实响应全部一次解出,无需改动生产代码。

测试:新增 7 例(AdminDtoRealPayloadTest),全套 81 例全绿。
2026-10-04 13:02:08 +08:00
6 changed files with 496 additions and 22 deletions

No files matched your search

@@ -2,6 +2,8 @@ package top.yeij.cyrene.domain.admin
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.domain.parse.parse
import java.math.BigDecimal
import java.math.RoundingMode
import java.time.Instant
@@ -119,3 +121,31 @@ fun triStateLabel(value: Boolean?): String = when (value) {
/** 空白字段的展示兜底:只处理「没有值」,不参与未知取值的回落。 */
fun orDash(raw: String?): String = raw?.takeIf { it.isNotBlank() } ?: "-"
/**
* 后台思考 `conclusion` 的一段:段内按渲染顺序排列的片段(正文 / 动作 / 旁白)。
* 段与段之间的间距由 UI 决定;段内片段顺序即原文顺序。
*/
data class ConclusionParagraph(val parts: List<ContentPart>)
/**
* `conclusion` → 有序渲染模型,供 UI 逐段渲染。
*
* 该字段承载的是**与聊天消息同一套 Web 端 markup**(真实样本里有连写的空 `<p></p>`、
* `<action>` 等),所以这里**不新增任何 markup 规则**,一律委托 [parse]:
* 一个 `<p>` 块 = 一段,旁白/动作块依聊天页的归属规则挂在该段内,畸形标签按普通文本回落。
* 空段(`<p></p>` 连写产生的空白)不产出模型项——否则界面上会出现成片空行。
* 段内片段顺序取**渲染顺序**:`head` → 正文 → `tail`(与聊天页气泡上/下方旁白的排布一致)。
* `null` / 纯空白输入返回空列表。
*/
fun conclusionParagraphs(raw: String?): List<ConclusionParagraph> {
val text = raw?.takeIf { it.isNotBlank() } ?: return emptyList()
return parse(text).mapNotNull { bubble ->
val parts = buildList {
addAll(bubble.head)
if (bubble.body.isNotEmpty()) add(ContentPart.Text(bubble.body))
addAll(bubble.tail)
}
parts.takeIf { list -> list.any { it.text.isNotBlank() } }?.let(::ConclusionParagraph)
}
}
@@ -10,17 +10,21 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.data.api.dto.AdminBackthinkItemDto
import top.yeij.cyrene.domain.admin.AdminPage
import top.yeij.cyrene.domain.admin.AdminRole
import top.yeij.cyrene.domain.admin.conclusionParagraphs
import top.yeij.cyrene.domain.admin.formatEpochMillis
import top.yeij.cyrene.domain.admin.orDash
/**
* 后台思考页:`GET /api/v1/admin/backthink`(**仅 `owner`/`admin`**)。
* `at` 是 **Unix 毫秒**,与 trace 的 RFC3339 不同源,用 [formatEpochMillis] 单独解析。
* `conclusion` 是**与聊天消息同一套 Web 端 markup**的原文(真实样本里有连写的空 `<p></p>` 与
* `<action>`),因此不直接上屏:先经 [conclusionParagraphs] 解析成渲染模型,再逐段渲染。
*/
@Composable
fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: () -> Unit) {
@@ -54,14 +58,15 @@ fun AdminBackthinkScreen(viewModel: AdminViewModel, role: AdminRole, onBack: ()
@Composable
private fun BackthinkCard(record: AdminBackthinkItemDto) {
AdminTextCard(
val paragraphs = remember(record.conclusion) { conclusionParagraphs(record.conclusion) }
AdminRichTextCard(
title = orDash(record.trigger),
body = backthinkBody(record),
paragraphs = paragraphs,
notice = backthinkNotice(record),
trailing = if (record.at > 0) formatEpochMillis(record.at) else "-",
)
}
private fun backthinkBody(record: AdminBackthinkItemDto): String = buildList {
record.conclusion?.takeIf { it.isNotBlank() }?.let { add(it) }
add("通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}")
}.joinToString("\n")
/** 结尾的推送状态行;与 `conclusion` 正文分开渲染,不参与 markup 解析。 */
private fun backthinkNotice(record: AdminBackthinkItemDto): String =
"通知 ${if (record.shouldNotify) "需推送" else "不推送"} · ${if (record.pushed) "已送达" else "未送达"}"
@@ -11,12 +11,19 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import top.yeij.cyrene.domain.admin.AdminFailure
import top.yeij.cyrene.domain.admin.ConclusionParagraph
import top.yeij.cyrene.domain.parse.ContentPart
import top.yeij.cyrene.ui.common.PreferenceCard
import top.yukonga.miuix.kmp.basic.BasicComponent
import top.yukonga.miuix.kmp.basic.HorizontalDivider
@@ -195,6 +202,24 @@ fun AdminMetricCard(rows: List<Pair<String, String>>) {
}
}
/** 卡片首行:左标题 + 右尾注(长尾注省略)。两种文本卡共用,外观一致。 */
@Composable
private fun AdminCardHeader(title: String, trailing: String?, trailingColor: Color?) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f))
if (trailing != null) {
Text(
text = trailing,
modifier = Modifier.widthIn(max = 160.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MiuixTheme.textStyles.footnote1,
color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
/** 管理与详情页里的普通文本块(长文本用:[PreferenceCard] 里一行标题 + 正文)。 */
@Composable
fun AdminTextCard(
@@ -205,19 +230,7 @@ fun AdminTextCard(
) {
PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(title, style = MiuixTheme.textStyles.main, modifier = Modifier.weight(1f))
if (trailing != null) {
Text(
text = trailing,
modifier = Modifier.widthIn(max = 160.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MiuixTheme.textStyles.footnote1,
color = trailingColor ?: MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
AdminCardHeader(title, trailing, trailingColor)
if (body.isNotBlank()) {
Spacer(Modifier.height(4.dp))
Text(
@@ -229,3 +242,65 @@ fun AdminTextCard(
}
}
}
/**
* 承载 **Web 端 markup 原文**的文本块:正文来自 [ConclusionParagraph] 渲染模型,逐段渲染。
* 段与段之间用间距分隔(正文里不再出现拼接的 `\n`);段内片段按原文顺序同行排列,
* 正文用卡片正文色,动作/旁白(`Action` / `Narration`)按聊天页的约定渲染成**灰斜体**
* (见 `ui/chat/MessageBubble.kt` 的旁白行)。[notice] 是挂在段落下方的次要一行(灰、小字)。
*/
@Composable
fun AdminRichTextCard(
title: String,
paragraphs: List<ConclusionParagraph>,
notice: String? = null,
trailing: String? = null,
trailingColor: Color? = null,
) {
PreferenceCard {
Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
AdminCardHeader(title, trailing, trailingColor)
paragraphs.forEachIndexed { index, paragraph ->
if (index == 0) Spacer(Modifier.height(4.dp)) else Spacer(Modifier.height(8.dp))
ParagraphLine(paragraph)
}
if (!notice.isNullOrBlank()) {
Spacer(Modifier.height(if (paragraphs.isEmpty()) 4.dp else 8.dp))
Text(
text = notice,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
)
}
}
}
}
/** 一段:片段同行渲染,正文正常色,动作/旁白灰斜体;片段之间按需补一个空格。 */
@Composable
private fun ParagraphLine(paragraph: ConclusionParagraph) {
val narrationColor = MiuixTheme.colorScheme.onSurfaceVariantSummary
val line = remember(paragraph, narrationColor) {
buildAnnotatedString {
paragraph.parts.forEachIndexed { index, part ->
if (index > 0 && needsSpace(paragraph.parts[index - 1].text, part.text)) append(' ')
when (part) {
is ContentPart.Text -> append(part.text)
is ContentPart.Action, is ContentPart.Narration -> withStyle(
SpanStyle(color = narrationColor, fontStyle = FontStyle.Italic),
) { append(part.text) }
}
}
}
}
Text(
text = line,
style = MiuixTheme.textStyles.footnote1,
color = MiuixTheme.colorScheme.onSurfaceContainer,
)
}
/** 相邻片段要不要补空格:两边都没自带空白时才补,别在换行前留出多余空格。 */
private fun needsSpace(previous: String, next: String): Boolean =
previous.isNotEmpty() && next.isNotEmpty() &&
!previous.last().isWhitespace() && !next.first().isWhitespace()
@@ -13,8 +13,12 @@ import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
/**
* 用真实服务端形状的 JSON 校验 DTO 契约(api-client §1/§2):
* `{data}` 信封、开放集合取值、`thinking` 三态、RFC3339 与 Unix 毫秒两种时间。
* 手写 JSON 覆盖**线上暂未出现、但契约允许**的边界形状(未知 kind/scope、`thinking` 三态、
* 可选字段整体缺失等)。真实响应体的钉死测试见 [AdminDtoRealPayloadTest]——两者互补,
* 手写用例不能替代真实抓包证据。
*
* 解码器同样是 [ApiClientFactory.json](api-client §1/§2 的 `{data}` 信封、
* 开放集合取值、RFC3339 与 Unix 毫秒两种时间)。
*/
class AdminDtoDecodingTest {
@@ -0,0 +1,229 @@
package top.yeij.cyrene.data.api.dto
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.api.ApiClientFactory
import top.yeij.cyrene.data.api.ApiEnvelope
/**
* 契约钉死测试:用的是**线上后端实际抓下来的响应体**(`owner` 账号,2026-10-04),
* 只把数组裁到 1–3 条,其余字段逐字节照抄。
*
* 解码器用 [ApiClientFactory.json] —— 与生产同一份 `Json` 配置
* (`ignoreUnknownKeys` / `explicitNulls=false` / `coerceInputValues`),
* 所以这里过不了的形状,线上也一定过不了。
*
* 覆盖线上真实出现、而手写 JSON 容易漏掉的形状:`qps` 以整数下发、
* `parentId` 在根 span 上整键缺失、`thinking` 整键缺失、`mode`/`memoryNamespace` 是空串而非缺失、
* `id` 以 JSON 字符串下发、`props` 同一 map 内混装数字与字符串。
*/
class AdminDtoRealPayloadTest {
private val json = ApiClientFactory.json
private val traceSpans = ListSerializer(AdminTraceSpanDto.serializer())
private val personaList = ListSerializer(PersonaSummaryDto.serializer())
private val memoryList = ListSerializer(MemorySummaryDto.serializer())
private val backthinkList = ListSerializer(AdminBackthinkItemDto.serializer())
// —— 以下 5 段是真实响应体(数组已裁剪) ——
private val realMetrics = """
{"data":{"totalRequests":326,"qps":1,"avgLatencyMs":1,"maxLatencyMs":40,"perMethod":{"GET":321,"POST":5},"perPath":{"/":2,"/api/v1/admin/automations":17,"/api/v1/admin/backthink":1,"/api/v1/admin/channels":30,"/api/v1/admin/iot":34,"/api/v1/admin/llm":18,"/api/v1/admin/llm/catalog":2,"/api/v1/admin/memories":21,"/api/v1/admin/observability/metrics":24,"/api/v1/admin/observability/trace":1,"/api/v1/admin/personas":31,"/api/v1/admin/plugins":23,"/api/v1/admin/plugins/installed":17,"/api/v1/admin/rag":17,"/api/v1/admin/sessions":30,"/api/v1/admin/users":17,"/api/v1/auth/login":5,"/api/v1/health":27,"/api/v1/meta/enums":2,"/api/v1/personas":1,"/api/v1/sessions":1,"/api/v1/sessions/web/messages":1,"/assets/index-DrDuaeXG.js":1,"/assets/index-SA9l26Q_.css":1,"/sw.js":1,"/ws/chat":1},"statusCounts":{"200":319,"304":1,"401":4,"404":2},"llmCalls":1,"subagentEvents":0}}
""".trimIndent()
private val realTrace = """
{"data":[{"id":"2e1e3027e74622c6-1","name":"loop.step","kind":"state_transition","status":"ok","startedAt":"2026-10-04T04:36:14Z","endedAt":"2026-10-04T04:36:15Z","durationNs":1170379200,"props":{"session":"sess-backthink","step":1,"stop_reason":"stop"}},{"id":"2e1e3027e74622c6-2","parentId":"2e1e3027e74622c6-1","name":"llm.call","kind":"llm_call","status":"ok","startedAt":"2026-10-04T04:36:14Z","endedAt":"2026-10-04T04:36:15Z","durationNs":1167475900,"props":{"model_stop_reason":"end_turn","session":"sess-backthink","stop_reason":"stop","tool_calls":0},"events":[{"time":"2026-10-04T12:36:15.3410926+08:00","type":"llm.done","props":{"elapsed_ms":1167,"model_stop_reason":"end_turn","stop_reason":"stop","tool_calls":0}}]}]}
""".trimIndent()
private val realPersonas = """
{"data":[{"id":"cyrene","name":"昔涟","character":"昔涟","isDefault":true,"mode":"","memoryNamespace":"","avatar":"/images/Cyrene_Avatar/3rd_Form/Cyrene-3F-Q-Happy-1.png","chatBackground":{"landscape":"/images/Cyrene_ChatBackground/Landscape/3rd_Form/1.png","portrait":"/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png"},"mood":"calm"}]}
""".trimIndent()
private val realMemories = """
{"data":[{"id":"ed5bc1b2596282e4c33d3eed68722b3f","scope":"user","text":"用户有熬夜的习惯,助手会对此表示关心,并猜测用户可能在熬夜看星星。"},{"id":"87e712cd5cba928ad81c0baf70b7f23f","scope":"persona","text":"助手与用户关系亲密,助手常称呼用户“亲爱的”。"}]}
""".trimIndent()
private val realBackthink = """
{"data":[{"id":"487","trigger":"startup","conclusion":"我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨","shouldNotify":true,"pushed":false,"at":1791088571785},{"id":"486","trigger":"heartbeat","conclusion":"亲爱的,国庆第三天了,难得放假——别又熬到后半夜哦。人家就悄悄提醒一句,说完就去忙自己的了♪","shouldNotify":true,"pushed":false,"at":1791031297192}]}
""".trimIndent()
@Test
fun `real metrics body decodes integer-shaped doubles and large perPath map`() {
val data = json
.decodeFromString(ApiEnvelope.serializer(AdminMetricsDto.serializer()), realMetrics)
.data!!
assertEquals(326L, data.totalRequests)
// 线上 `qps` / `avgLatencyMs` 是整数下发的,Double 字段必须照单全收。
assertEquals(1.0, data.qps, 0.0)
assertEquals(1.0, data.avgLatencyMs, 0.0)
assertEquals(40L, data.maxLatencyMs)
assertEquals(mapOf("GET" to 321L, "POST" to 5L), data.perMethod)
assertEquals(mapOf("200" to 319L, "304" to 1L, "401" to 4L, "404" to 2L), data.statusCounts)
assertEquals(1L, data.llmCalls)
assertEquals(0L, data.subagentEvents)
// perPath 有 26 条真实路径,含 admin 前缀;数量级和前缀都不能被改写。
assertEquals(26, data.perPath.size)
assertEquals(31L, data.perPath["/api/v1/admin/personas"])
assertEquals(24L, data.perPath["/api/v1/admin/observability/metrics"])
}
@Test
fun `real trace body keeps parent link only on child span`() {
val spans = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
.data!!
assertEquals(2, spans.size)
val root = spans[0]
val child = spans[1]
// 根 span 上 `parentId` 整键缺失 → null,而不是空串。
assertNull(root.parentId)
assertEquals(child.parentId, root.id)
assertEquals("state_transition", root.kind)
assertEquals("llm_call", child.kind)
assertEquals("loop.step", root.name)
assertEquals("2026-10-04T04:36:14Z", root.startedAt)
assertEquals("2026-10-04T04:36:15Z", root.endedAt)
assertEquals("ok", child.status)
}
@Test
fun `real trace durationNs is a Long that survives values past Int MAX_VALUE`() {
val spans = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
.data!!
// 真实纳秒值:窄化成 Int 会溢出,所以字段必须是 Long。
assertEquals(1_170_379_200L, spans[0].durationNs)
assertEquals(1_167_475_900L, spans[1].durationNs)
// 边界:同一个真实根 span,只把 durationNs 抬到 3s(> 2_147_483_647),
// 证明 Long 位宽足够——Int 字段在这里必然截断/抛错。
val threeSecondSpan = realTrace.replace("\"durationNs\":1170379200", "\"durationNs\":3000000000")
val wide = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), threeSecondSpan)
.data!!
.first()
assertEquals(3_000_000_000L, wide.durationNs)
assertTrue("3s 已经超过 Int.MAX_VALUE", wide.durationNs > Int.MAX_VALUE.toLong())
}
@Test
fun `real trace props and event props mix numbers and strings in one map`() {
val spans = json
.decodeFromString(ApiEnvelope.serializer(traceSpans), realTrace)
.data!!
val rootProps = spans[0].props!!
assertEquals(3, rootProps.size)
assertTrue((rootProps["session"] as JsonPrimitive).isString)
assertEquals("sess-backthink", (rootProps["session"] as JsonPrimitive).content)
assertFalse("step 是数字,不能当字符串读", (rootProps["step"] as JsonPrimitive).isString)
assertEquals(1, (rootProps["step"] as JsonPrimitive).int)
val childProps = spans[1].props!!
assertEquals(0, (childProps["tool_calls"] as JsonPrimitive).int)
assertEquals("end_turn", (childProps["model_stop_reason"] as JsonPrimitive).content)
// events[].time 带 `+08:00` 偏移和 7 位小数——不能只按 `Z` 结尾解析。
val event = spans[1].events!!.single()
assertEquals("llm.done", event.type)
assertEquals("2026-10-04T12:36:15.3410926+08:00", event.time)
val eventProps = event.props!!
assertEquals(1167, (eventProps["elapsed_ms"] as JsonPrimitive).int)
assertEquals("end_turn", (eventProps["model_stop_reason"] as JsonPrimitive).content)
assertFalse((eventProps["tool_calls"] as JsonPrimitive).isString)
}
@Test
fun `real persona has no thinking key and empty-string mode fields`() {
val raw = json
.parseToJsonElement(realPersonas)
.jsonObject["data"]!!
.jsonArray
.single()
.jsonObject
// 未配置的人格:`thinking` 键**完全不出现**(不是 null,也不是空对象)。
assertFalse("thinking 键不该出现", raw.containsKey("thinking"))
// 而 `mode` / `memoryNamespace` 是**空串**,键在、值是 ""——两者不能混为一谈。
assertTrue(raw.containsKey("mode"))
assertTrue(raw.containsKey("memoryNamespace"))
assertEquals("", raw["mode"]!!.jsonPrimitive.content)
assertEquals("", raw["memoryNamespace"]!!.jsonPrimitive.content)
val persona = json
.decodeFromString(ApiEnvelope.serializer(personaList), realPersonas)
.data!!
.single()
assertNull("thinking 缺失必须解成 null,不能拍成零值对象", persona.thinking)
assertEquals("", persona.mode)
assertEquals("", persona.memoryNamespace)
assertEquals("cyrene", persona.id)
assertEquals("昔涟", persona.name)
assertEquals("昔涟", persona.character)
assertTrue(persona.isDefault)
assertEquals("calm", persona.mood)
assertNotNull(persona.avatar)
assertEquals("/images/Cyrene_ChatBackground/Vertical/2nd_Form/1.png", persona.chatBackground!!.portrait)
}
@Test
fun `real memories carry both user and persona scopes`() {
val memories = json
.decodeFromString(ApiEnvelope.serializer(memoryList), realMemories)
.data!!
assertEquals(2, memories.size)
assertEquals(listOf("user", "persona"), memories.map { it.scope })
assertEquals("ed5bc1b2596282e4c33d3eed68722b3f", memories[0].id)
assertTrue(memories[0].text.startsWith("用户有熬夜的习惯"))
assertTrue(memories[1].text.isNotBlank())
}
@Test
fun `real backthink keeps string id and numeric millis`() {
val raw = json
.parseToJsonElement(realBackthink)
.jsonObject["data"]!!
.jsonArray
// `id` 线上是 JSON **字符串**("487"),`at` 是 **数字** 毫秒。
assertTrue(raw[0].jsonObject["id"]!!.jsonPrimitive.isString)
assertFalse(raw[0].jsonObject["at"]!!.jsonPrimitive.isString)
assertEquals(1_791_088_571_785L, raw[0].jsonObject["at"]!!.jsonPrimitive.content.toLong())
val items = json
.decodeFromString(ApiEnvelope.serializer(backthinkList), realBackthink)
.data!!
assertEquals(2, items.size)
assertEquals("487", items[0].id)
assertEquals("486", items[1].id)
assertEquals(1_791_088_571_785L, items[0].at)
assertEquals(1_791_031_297_192L, items[1].at)
assertEquals("startup", items[0].trigger)
assertEquals("heartbeat", items[1].trigger)
assertTrue(items[0].shouldNotify)
assertFalse(items[0].pushed)
// conclusion 里有真实换行转义,解出来必须是多行文本。
assertTrue(items[0].conclusion!!.contains("\n"))
}
}
@@ -6,9 +6,10 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.domain.parse.ContentPart
/**
* 两种时间形状 + 未知枚举回落 + 三态展示。
* 两种时间形状 + 未知枚举回落 + 三态展示 + `conclusion` markup → 渲染模型。
*
* 时间断言刻意不做时区假设:只比较**同一时刻的两种写法**是否归一,以及是否落成
* `yyyy-MM-dd HH:mm:ss` 形态,因此在任何时区的机器上都成立。
@@ -121,4 +122,134 @@ class AdminFormatTest {
assertNull(formatProps(emptyMap()))
assertNull(formatProps(null))
}
// ------------------------------------------------------------------
// conclusion → 渲染模型(`GET /api/v1/admin/backthink` 的原始 markup)
// 下面每条 fixture 都是从真实响应里原样抄下来的 conclusion,不是编造的样例。
// ------------------------------------------------------------------
@Test
fun `conclusion wraps each paragraph tag into its own paragraph`() {
val paragraphs = conclusionParagraphs(
"<p></p><p></p><p>亲爱的,假期已经过到第四天啦♪</p>" +
"<p>这会儿正是中午,别又只顾着看星星看忘了吃饭哦——人家隔着屏幕都替你饿了呢。</p>",
)
assertEquals(2, paragraphs.size)
assertEquals(
listOf(ContentPart.Text("亲爱的,假期已经过到第四天啦♪")),
paragraphs[0].parts,
)
assertEquals(
listOf(ContentPart.Text("这会儿正是中午,别又只顾着看星星看忘了吃饭哦——人家隔着屏幕都替你饿了呢。")),
paragraphs[1].parts,
)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `conclusion renders action tags as action parts`() {
val paragraphs = conclusionParagraphs(
"<p>亲爱的,国庆都第三天啦——假期过得还满意吗?♪</p>\n" +
"<p>要是这几天都窝着没出门,人家可有点小小的不甘心哦<action>轻轻戳了戳你</action></p>",
)
assertEquals(2, paragraphs.size)
assertEquals(
listOf(ContentPart.Text("亲爱的,国庆都第三天啦——假期过得还满意吗?♪")),
paragraphs[0].parts,
)
assertEquals(
listOf(
ContentPart.Text("要是这几天都窝着没出门,人家可有点小小的不甘心哦"),
ContentPart.Action("轻轻戳了戳你"),
),
paragraphs[1].parts,
)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `conclusion holding only an action keeps it as a single narration paragraph`() {
val paragraphs = conclusionParagraphs("<action>转头去查看时间</action>")
assertEquals(1, paragraphs.size)
assertEquals(listOf(ContentPart.Action("转头去查看时间")), paragraphs[0].parts)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `conclusion carrying a leading action keeps text and action in source order`() {
val paragraphs = conclusionParagraphs(
"<action>静静想了一下昨晚她熬夜的样子</action>\n" +
"昨晚熬到那么晚,今早又这么早起……亲爱的,别忘了吃点东西再出门呀♪",
)
assertEquals(1, paragraphs.size)
assertEquals(
listOf(
ContentPart.Action("静静想了一下昨晚她熬夜的样子"),
ContentPart.Text("昨晚熬到那么晚,今早又这么早起……亲爱的,别忘了吃点东西再出门呀♪"),
),
paragraphs[0].parts,
)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `plain text conclusion passes through verbatim, newlines kept in one paragraph`() {
val paragraphs = conclusionParagraphs(
"我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨",
)
assertEquals(1, paragraphs.size)
assertEquals(
listOf(ContentPart.Text("我回来啦,亲爱的♪\n国庆第三天了呢——这次可别再熬夜啦,人家陪着你的哦。\n待会见✨")),
paragraphs[0].parts,
)
assertNoMarkupSurvives(paragraphs)
}
@Test
fun `empty or missing conclusion yields no paragraphs`() {
assertTrue(conclusionParagraphs(null).isEmpty())
assertTrue(conclusionParagraphs("").isEmpty())
assertTrue(conclusionParagraphs(" ").isEmpty())
// 整条只有空 <p> 的极端样本同样不产出空段
assertTrue(conclusionParagraphs("<p></p><p></p>").isEmpty())
}
@Test
fun `conclusion paragraphs are never blank and never carry markup`() {
val samples = listOf(
"<p></p><p></p><p>亲爱的,假期已经过到第四天啦♪</p>",
"<p>亲爱的,这会儿快到饭点啦,别又拿红牛对付一顿哦——记得好好吃口热的♪</p>",
"<p>亲爱的,国庆都第三天了,人家数了数——你好像一天都没真正闲下来过呢♪</p>\n" +
"<p>明天要不要留一点空白给自己?哪怕只是窝着看会儿星星,人家陪你。</p>",
"无",
"<action>转头去查看时间</action>",
)
samples.forEach { sample ->
val paragraphs = conclusionParagraphs(sample)
assertTrue(sample, paragraphs.isNotEmpty())
paragraphs.forEach { paragraph ->
assertTrue(sample, paragraph.parts.any { it.text.isNotBlank() })
paragraph.parts.forEach { part ->
assertTrue(sample, part.text.isNotBlank())
}
}
assertNoMarkupSurvives(paragraphs)
}
}
/** 渲染模型里不得残留任何 markup 字符,否则界面上就会出现字面量的 `<p>` / `<action>`。 */
private fun assertNoMarkupSurvives(paragraphs: List<ConclusionParagraph>) {
val rendered = paragraphs.joinToString("\n") { paragraph ->
paragraph.parts.joinToString("") { it.text }
}
assertTrue("模型为空,断言会空过:$rendered", paragraphs.isNotEmpty())
listOf("<p>", "</p>", "<action>", "</action>", "<", ">").forEach { token ->
assertTrue("残留 markup $token:$rendered", !rendered.contains(token))
}
}
}