feat(chat): 忙时并行回合的客户端半边 —— 六态 + 灰态 + (sessionId, turnId) 两级分桶 + 新帧消费(全部可降级)
依据:Cyrene_v2/DevDocs/_specs/忙时并行-规范草案.md(§1 行为 / §2 接口 / §4 主人裁决)。 后端 v0.65 尚未落地这些帧,所以本次的新键与新帧全部按可降级实现:帧不来、字段缺席时, App 在今天的真后端上行为与改前逐字相同(下面的「降级保证」逐条给了证据)。 一、改了哪些文件 新增(纯逻辑,可单测) - app/src/main/kotlin/top/yeij/cyrene/domain/chat/ParallelTurns.kt 两级分桶键 TurnBucket(sessionId, turnId) 与 turnBucketOf / closingBucketOf; 六态枚举 TurnPhase 与 turnPhaseOf;灰态收口判据 closesAnswering; 消息↔回合绑定 TurnBinding 及其纯函数(withTurnBegin / withSteeredMessage / closingTurn / allClosed / bindingOfMessage);路小标文案 legBadgeOf。 - app/src/main/kotlin/top/yeij/cyrene/ui/chat/TurnStreams.kt StreamingLeg(bucket, StreamingState) 与 withLeg / beginLeg / finishingLeg / withoutLeg; 六态在状态上的推导 ChatUiState.phaseOf(消息 / 乐观气泡); 路小标与「跳到提问」的查表 legBadgeForMessage / legBadgeForTurn / jumpIndexForTurn。 修改 - data/api/dto/MessageDto.kt:HistoryMessageDto 增可选 turnId(历史行 / 落库行都用它)。 - data/api/dto/ChatDto.kt:ChatRequest 增可选 parallel(REST 与 WS chat 同形)。 - data/ws/WsOutbound.kt:chat 增可选 parallel、interrupt 增可选 turnId,省略即整键不进线上。 - data/ws/WsEvent.kt:新增 turn_begin / turn.end / steer_ack 三个事件与其载荷; session_busy / session_idle / pending_drain 增可选 activeTurns,session_interrupted 增 activeTurns + cancelledTurns,pending_ack 增可选 reason。 - ui/chat/ChatViewModel.kt:流式解析器与「本回合见过结构化正文帧」由单值升为按桶的 map/set, 新增「本会话最近见过的 turnId」锚点;消费 turn_begin / steer_ack / turn.end; 助手落库行按其 turnId 收口灰态并只摘掉它自己那一路的流式气泡;排队回执记进 queuedKeys。 - ui/chat/MessageBubble.kt:MessageBubble / PendingBubble 增 phase 与 legBadge, 正文气泡增灰态配色,新增并行路小标(可点即跳);角标文案抽成纯函数 bubbleFooter。 - ui/chat/ChatScreen.kt:按桶取每一路的流式状态并渲染;传入六态与小标;忙碌行加「并行 N 路」。 - 新增 4 个测试文件(共 82 条):ParallelTurnsTest、TurnStreamsTest、 WsEventTurnFramesTest、WsOutboundParallelTest。 二、为什么这么做 - 界面必须按 (sessionId, turnId) 两级分桶(规范 §1.7 第 1 件硬要求):两路并行时两个不同 turnId 的 message.delta / thinking.delta / tool_call 会交错到达,照老代码「一律累加到当前那 一个流式气泡」会把两条回复糊成一条(规范 §1.4 点名的那件事)。现在每一路各有一份解析器、 一份思维链、一份工具卡表、一个流式气泡。 - 灰态(④ 已读、未答)的唯一权威收口事件是 turn.end(规范 §1.2)。本端另外在 stream_end (既有的「整轮结束」帧,v0.36 起带 turnId)与「同 turnId 的助手落库行」上也收口: 前者在 turn.end 到位之前是唯一能收口的帧,后者是规范给的兜底。三者顺序不定、重复到达都无害 (纯函数幂等)。 - 六态判定与收口判据的不变量都抽成纯函数,可直接单测;判定顺序即证据强度: 被拒 > 已绑定未收口 > 已绑定已收口 > 本地乐观气泡 > 排队回执。 三、字段缺失时的降级保证(本次最要紧的一条) - 全链路没有一处「turnId 为空就不渲染」。turnBucketOf 在帧壳没给 turnId 时先回落本会话最近 见过的 turnId、再回落匿名桶(turnId 为空串)⇒ 所有无 turnId 的帧落在同一个桶里, 那就是老代码那唯一一个流式气泡。 - turn_begin / steer_ack.turnId / turn.end 不来 ⇒ 一条绑定都建不起来 ⇒ 六态里只可能出现 ①②③⑤,灰态根本不会进入;界面与改前逐字相同。 - activeTurns 缺席时不动这个计数(null 是「不知道」,0 是「确实空闲」,两件事不许互相顶替); pending_ack.reason 缺席就是 null,绝不伪造。 - 上行三个新字段(chat.parallel、interrupt.turnId、ChatRequest.parallel)本端今天一个字节都 不发:真并行要用户显式声明,而声明入口不在本次范围内。服务端照旧走排队 + 回合内 steer。 - 已被单测钉住的证据: ParallelTurnsTest「所有无 turnId 的帧落进同一个桶」「两个空白 turnId 不算匹配,绝不收口」; TurnStreamsTest「单路时长度恒为 1」「没有任何 turn 帧时已落库=②、本地气泡=①」; WsEventTurnFramesTest「老形状的帧解出来新键一律为 null(不是 0、不是空串)」; WsOutboundParallelTest「省略时那条帧逐字节相同,且不带该键」。 四、等 v0.65 的部分 - turn_begin / steer_ack.turnId / turn.end 的真实到达,历史行的可选 turnId,activeTurns / pending_ack.reason / cancelledTurns 的真实取值。 - 真并行的触发入口(谁在什么时候把 chat.parallel 置 true)——本次只备好线上形状,不接 UI。 - 仍未决且影响客户端的两项:graph_update 两路互相顶掉(规范未决 10,等 design/19 裁决); 按 turnId 单独核算用量(规范未决 14,可选)。 五、规范里与本端既有定案冲突的一处(如实登记) 规范 §1.1 ⑥「被拒」要求气泡带红色「未送达 · 会话正忙」并可重试;本端被拒(pending_full / 定向 chat_busy)的既有定案是摘掉乐观气泡 + 横幅如实告知 + 正文还给输入栏(可再按一次发送, 幂等键保证不会落成第二条消息)。既有行为由现有测试钉住,本次不改;六态判定里保留了 Rejected 档并由单测直接覆盖,只是本端的现实路径走的是回滚那一支。 验证:assembleDebug 绿 + testDebugUnitTest 绿(0 失败 0 错误 0 跳过)。
This commit is contained in:
1 parent
5e6dc76ae8
commit
29299b190e
13 files changed
+2124
-116
No files matched your search
@@ -0,0 +1,224 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 忙时并行的**下行帧**:新帧 `turn_begin`、`turn.end`、`steer_ack` 的帧壳 `turnId`,
|
||||
* 以及忙态帧的可选计数与 `pending_ack.reason`(规范 §2.4 ~ §2.7)。
|
||||
*
|
||||
* 两组断言各占一半:
|
||||
* - **新键到位**(并行真的开起来时客户端有东西可用);
|
||||
* - **老帧逐字不变**(v0.64 形状的帧解出来,新键一律是 `null`——`null` 是「没给」,
|
||||
* 不是 0、不是空串;消费方据此一个字节都不动)。
|
||||
*/
|
||||
class WsEventTurnFramesTest {
|
||||
|
||||
// —— 新帧 turn_begin(规范 §2.4)——
|
||||
|
||||
@Test
|
||||
fun `turn begin is parsed with every field of the spec`() {
|
||||
val frame = """
|
||||
{"type":"turn_begin","sessionId":"web","turnId":"turn-9f3c1a2b3c4d5e6f",
|
||||
"data":{"kind":"parallel","messageIds":["msg-7f2a19c4e5"],"leg":2,"activeTurns":2}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame)
|
||||
|
||||
assertTrue("turn_begin 必须被认出来(它是灰态的唯一入口)", event is WsEvent.TurnBegin)
|
||||
event as WsEvent.TurnBegin
|
||||
assertEquals("web", event.sessionId)
|
||||
assertEquals("turn-9f3c1a2b3c4d5e6f", event.turnId)
|
||||
assertEquals("parallel", event.kind)
|
||||
assertEquals(listOf("msg-7f2a19c4e5"), event.messageIds)
|
||||
assertEquals(2, event.leg)
|
||||
assertEquals(2, event.activeTurns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a turn begin without a shell turnId keeps its message ids but no turn id`() {
|
||||
// 帧壳 turnId 是这一路的唯一权威标识:没有它就不能建立绑定(绑了也收不了口)。
|
||||
// 解析层如实给出 null,由消费方决定「只消费 activeTurns」。
|
||||
val frame = """{"type":"turn_begin","sessionId":"web","data":{"kind":"turn","messageIds":["m-1"],"leg":1}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.TurnBegin
|
||||
|
||||
assertNull(event.turnId)
|
||||
assertEquals(listOf("m-1"), event.messageIds)
|
||||
assertEquals(0, event.activeTurns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a turn begin falls back to the session id inside the payload`() {
|
||||
// 规范说会话 id 只在帧壳里(data 内不重复);真出现了也照既有口径取有值者。
|
||||
val frame = """{"type":"turn_begin","data":{"sessionId":"web:abc","messageIds":["m-1"]}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.TurnBegin
|
||||
|
||||
assertEquals("web:abc", event.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank message ids inside turn begin are dropped`() {
|
||||
val frame = """{"type":"turn_begin","sessionId":"web","turnId":"t1","data":{"messageIds":["m-1"," "]}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.TurnBegin
|
||||
|
||||
assertEquals(listOf("m-1"), event.messageIds)
|
||||
}
|
||||
|
||||
// —— steer_ack 的帧壳 turnId(规范 §2.5)——
|
||||
|
||||
@Test
|
||||
fun `steer ack carries the shell turn id next to its v0_57 payload`() {
|
||||
val frame = """
|
||||
{"type":"steer_ack","sessionId":"web","turnId":"turn-9f3c1a2b3c4d5e6f",
|
||||
"data":{"messageId":"msg-7f2a19c4e5","atStep":2}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame)
|
||||
|
||||
assertTrue(event is WsEvent.SteerAck)
|
||||
event as WsEvent.SteerAck
|
||||
assertEquals("web", event.sessionId)
|
||||
assertEquals("turn-9f3c1a2b3c4d5e6f", event.turnId)
|
||||
assertEquals("msg-7f2a19c4e5", event.messageId)
|
||||
assertEquals(2, event.atStep)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a legacy steer ack without a turn id still parses with a null turn id`() {
|
||||
// v0.57 ~ v0.64 的帧:事件照旧成形(不会被当未知帧丢掉),只是没有 turnId 可以绑定。
|
||||
val frame = """{"type":"steer_ack","sessionId":"web","data":{"messageId":"m-1","atStep":1}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.SteerAck
|
||||
|
||||
assertNull(event.turnId)
|
||||
assertEquals("m-1", event.messageId)
|
||||
}
|
||||
|
||||
// —— turn.end(规范 §1.2 的收口事件)——
|
||||
|
||||
@Test
|
||||
fun `turn end is parsed from the frame shell alone`() {
|
||||
val frame = """{"type":"turn.end","sessionId":"web","turnId":"turn-1"}"""
|
||||
|
||||
val event = WsEvent.parse(frame)
|
||||
|
||||
assertTrue(event is WsEvent.TurnEnd)
|
||||
event as WsEvent.TurnEnd
|
||||
assertEquals("web", event.sessionId)
|
||||
assertEquals("turn-1", event.turnId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `turn end without any payload is not a bad frame`() {
|
||||
// 后端 `streamEventData` 对 `StreamKindTurnEnd` 返回 nil(载荷天然是空的)。
|
||||
// 若解析层要求 data 对象才认,这一帧就会被丢掉 —— 而那正是「灰态收不了口」的成因。
|
||||
assertTrue(WsEvent.parse("""{"type":"turn.end","sessionId":"web","turnId":"t1"}""") is WsEvent.TurnEnd)
|
||||
assertTrue(WsEvent.parse("""{"type":"turn.end"}""") is WsEvent.TurnEnd)
|
||||
}
|
||||
|
||||
// —— 忙态帧的可选计数(规范 §2.6)——
|
||||
|
||||
@Test
|
||||
fun `session busy carries active turns when the server reports them`() {
|
||||
val frame = """{"type":"session_busy","sessionId":"web","data":{"sessionId":"web","mode":"executing","activeTurns":2}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.SessionBusy
|
||||
|
||||
assertEquals("executing", event.mode)
|
||||
assertEquals(2, event.activeTurns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing active turns key stays null rather than becoming zero`() {
|
||||
// 「缺席」与「0」绝不能互相顶替:拿缺席当 0 会把并行两路误报成空闲。
|
||||
val old = WsEvent.parse("""{"type":"session_busy","sessionId":"web","data":{"sessionId":"web","mode":"thinking"}}""")
|
||||
|
||||
assertNull((old as WsEvent.SessionBusy).activeTurns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicit zero active turns is reported as zero`() {
|
||||
val frame = """{"type":"session_idle","sessionId":"web","data":{"sessionId":"web","mode":"idle","activeTurns":0}}"""
|
||||
|
||||
assertEquals(0, (WsEvent.parse(frame) as WsEvent.SessionIdle).activeTurns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending drain and interrupted carry their optional counts`() {
|
||||
val drain = WsEvent.parse(
|
||||
"""{"type":"pending_drain","sessionId":"web","data":{"sessionId":"web","mode":"thinking","queueDepth":1,"activeTurns":1}}""",
|
||||
) as WsEvent.PendingDrain
|
||||
assertEquals(1, drain.activeTurns)
|
||||
|
||||
val interrupted = WsEvent.parse(
|
||||
"""{"type":"session_interrupted","sessionId":"web","data":{"sessionId":"web","mode":"executing","dropped":0,"cancelledTurns":1,"activeTurns":1}}""",
|
||||
) as WsEvent.SessionInterrupted
|
||||
assertEquals(1, interrupted.cancelledTurns)
|
||||
assertEquals(1, interrupted.activeTurns)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pending ack carries the reason code when the server gives one`() {
|
||||
val frame =
|
||||
"""{"type":"pending_ack","sessionId":"web","data":{"sessionId":"web","mode":"executing","queueDepth":2,"reason":"parallel_limit"}}"""
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.PendingAck
|
||||
|
||||
assertEquals("parallel_limit", event.reason)
|
||||
assertEquals(2, event.queueDepth)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary pending ack has no reason at all`() {
|
||||
// 服务端明写「其它情况该键缺席,绝不伪造」——所以这里必须是 null,不能是 ""。
|
||||
val frame = """{"type":"pending_ack","sessionId":"web","data":{"sessionId":"web","mode":"thinking","queueDepth":1}}"""
|
||||
|
||||
assertNull((WsEvent.parse(frame) as WsEvent.PendingAck).reason)
|
||||
}
|
||||
|
||||
// —— 历史行的可选 turnId(规范 §2.7)——
|
||||
|
||||
@Test
|
||||
fun `a persisted message may carry a turn id`() {
|
||||
val frame = """
|
||||
{"type":"message_persisted","sessionId":"web","data":{"message":
|
||||
{"id":"msg-7f2a19c4e5","sessionId":"web","role":"user","content":"顺便帮我查一下明天的天气",
|
||||
"createdAt":1760000000123,"turnId":"turn-9f3c1a2b3c4d5e6f"}}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.MessagePersisted
|
||||
|
||||
assertEquals("turn-9f3c1a2b3c4d5e6f", event.message?.turnId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an old persisted row has no turn id`() {
|
||||
val frame = """
|
||||
{"type":"message_persisted","sessionId":"web","data":{"message":
|
||||
{"id":"msg-1","sessionId":"web","role":"assistant","content":"好的","createdAt":1}}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.MessagePersisted
|
||||
|
||||
assertNull(event.message?.turnId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `history rows may carry turn ids and old rows simply do not`() {
|
||||
val frame = """
|
||||
{"type":"history","data":{"messages":[
|
||||
{"id":"m-1","sessionId":"web","role":"user","content":"甲","createdAt":1,"turnId":"t1"},
|
||||
{"id":"m-2","sessionId":"web","role":"assistant","content":"乙","createdAt":2}]}}
|
||||
""".trimIndent()
|
||||
|
||||
val event = WsEvent.parse(frame) as WsEvent.History
|
||||
|
||||
assertEquals("t1", event.messages[0].turnId)
|
||||
assertNull(event.messages[1].turnId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package top.yeij.cyrene.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 忙着并行规范的上行两处**纯增量**(§2.2 `chat.parallel` / §2.3 `interrupt.turnId`)。
|
||||
*
|
||||
* 本层只负责一件事:**省略时整键不进线上、给值时原样发出**。本端今天一个字节都不发这两个字段
|
||||
* (真并行要用户显式声明,声明入口不在本次范围内),所以这一组测试真正钉住的是
|
||||
* 「老形状的那条帧仍然逐字节相同」——那是与老服务端兼容的全部依据。
|
||||
*/
|
||||
class WsOutboundParallelTest {
|
||||
|
||||
/** 加入 `parallel` 之前那条 `chat` 帧的逐字形状。 */
|
||||
private val legacyChat = """{"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"d"}}"""
|
||||
|
||||
/** 加入 `turnId` 之前那条 `interrupt` 帧的逐字形状。 */
|
||||
private val legacyInterrupt = """{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}"""
|
||||
|
||||
// —— chat.parallel ——
|
||||
|
||||
@Test
|
||||
fun `a chat without parallel is today's frame byte for byte`() {
|
||||
assertEquals(legacyChat, WsOutbound.Chat(text = "你好", sessionId = "web", deviceId = "d").toJson())
|
||||
assertEquals(
|
||||
legacyChat,
|
||||
WsOutbound.Chat(text = "你好", sessionId = "web", deviceId = "d", parallel = null).toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `omitting parallel omits the key itself rather than sending null`() {
|
||||
val json = WsOutbound.Chat(text = "你好", sessionId = "web").toJson()
|
||||
|
||||
// `"parallel":null` 与「整键省略」在服务端不是一回事:后者才是「未声明」(规范 §2.2)。
|
||||
assertFalse("省略时不该出现 parallel 键:$json", json.contains("parallel"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parallel true is carried as a real boolean`() {
|
||||
val json = WsOutbound.Chat(text = "你好", sessionId = "web", parallel = true).toJson()
|
||||
|
||||
assertEquals("""{"type":"chat","data":{"sessionId":"web","text":"你好","parallel":true}}""", json)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parallel false is sent as false and never silently dropped`() {
|
||||
// 显式 false 与省略在服务端同义(都落回排队),但客户端**不改写**用户给了什么。
|
||||
assertEquals(
|
||||
"""{"type":"chat","data":{"sessionId":"web","text":"你好","parallel":false}}""",
|
||||
WsOutbound.Chat(text = "你好", sessionId = "web", parallel = false).toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
// —— interrupt.turnId ——
|
||||
|
||||
@Test
|
||||
fun `an interrupt without a turn id is today's frame byte for byte`() {
|
||||
assertEquals(legacyInterrupt, WsOutbound.Interrupt(sessionId = "web", scope = "current").toJson())
|
||||
assertEquals(
|
||||
legacyInterrupt,
|
||||
WsOutbound.Interrupt(sessionId = "web", scope = "current", turnId = null).toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a turn id is carried next to the scope`() {
|
||||
assertEquals(
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current","turnId":"turn-9f3c"}}""",
|
||||
WsOutbound.Interrupt(sessionId = "web", scope = "current", turnId = "turn-9f3c").toJson(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the session id stays first so older servers see the same key order`() {
|
||||
assertEquals(
|
||||
"""{"type":"interrupt","data":{"sessionId":"web","turnId":"turn-1"}}""",
|
||||
WsOutbound.Interrupt(sessionId = "web", turnId = "turn-1").toJson(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package top.yeij.cyrene.domain.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* 忙时并行的纯逻辑:**两级分桶 / 六态判定 / 灰态收口 / 消息↔回合绑定**。
|
||||
*
|
||||
* 这一层不碰协程也不碰 Android,所以「规范怎么说」与「代码怎么做」可以逐条对着钉死。
|
||||
* 三组测试的界线也是规范 §1.7 那三件事:分桶、绑定与收口、六态。
|
||||
*
|
||||
* 另外有一组**降级**测试(`字段缺失`):服务的今天没有 `turn_begin` / `steer_ack.turnId`,
|
||||
* 也没有 `parallel`,本模块必须做到「只按今天收到的帧走,行为与今天逐字相同」。
|
||||
*/
|
||||
class ParallelTurnsTest {
|
||||
|
||||
// —— 分桶(规范 §1.4 / §1.7 第 1 条)——
|
||||
|
||||
@Test
|
||||
fun `a frame without a turnId lands in the anonymous bucket`() {
|
||||
val bucket = turnBucketOf("web", null, null)
|
||||
|
||||
assertEquals(TurnBucket("web", ""), bucket)
|
||||
assertTrue(bucket.isAnonymous)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every frame without a turnId lands in the same bucket`() {
|
||||
// 这一条就是「字段缺失 ⇒ 行为与今天逐字相同」的判据:老代码只有一个流式气泡,
|
||||
// 分桶之后这些帧必须全部落在**同一个**桶里,否则一条回复会被劈成两半。
|
||||
val a = turnBucketOf("web", null, null)
|
||||
val b = turnBucketOf("web", " ", null)
|
||||
val c = turnBucketOf("web", "", "")
|
||||
|
||||
assertEquals(a, b)
|
||||
assertEquals(a, c)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the frame turnId wins over the remembered one`() {
|
||||
assertEquals(
|
||||
TurnBucket("web", "turn-2"),
|
||||
turnBucketOf("web", "turn-2", "turn-1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a missing turnId falls back to the remembered one`() {
|
||||
// 本会话正在跑 turn-1,某一帧没带 turnId ⇒ 它属于 turn-1(老代码里就是那唯一一个气泡)。
|
||||
assertEquals(
|
||||
TurnBucket("web", "turn-1"),
|
||||
turnBucketOf("web", null, "turn-1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank frame turnId counts as missing`() {
|
||||
assertEquals(
|
||||
TurnBucket("web", "turn-1"),
|
||||
turnBucketOf("web", " ", "turn-1"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two turns in one session never share a bucket`() {
|
||||
val leg1 = turnBucketOf("web", "turn-1", null)
|
||||
val leg2 = turnBucketOf("web", "turn-2", "turn-1")
|
||||
|
||||
assertFalse(leg1 == leg2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the same turnId in another session is another bucket`() {
|
||||
// 两级分桶的第二级:`turnId` 只在会话内唯一,跨会话不能合并。
|
||||
assertFalse(turnBucketOf("web", "turn-1", null) == turnBucketOf("web:abc", "turn-1", null))
|
||||
}
|
||||
|
||||
// —— 收口帧的落桶(不许新开桶)——
|
||||
|
||||
@Test
|
||||
fun `a closing frame lands on its own bucket when it is known`() {
|
||||
assertEquals(
|
||||
TurnBucket("web", "turn-1"),
|
||||
closingBucketOf("web", "turn-1", "turn-1", listOf("turn-1", "turn-2")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a closing frame with an unknown turnId falls back to the remembered bucket`() {
|
||||
assertEquals(
|
||||
TurnBucket("web", "turn-1"),
|
||||
closingBucketOf("web", "turn-9", "turn-1", listOf("turn-1")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a closing frame with no recognisable id lands on the most recent bucket`() {
|
||||
// 为什么这条不能省:老代码里 `stream_end` 无条件收口那唯一一个气泡。若给它新开一个桶,
|
||||
// 真正在跑的那个桶就永远收不了口(光标一直转)。
|
||||
assertEquals(
|
||||
TurnBucket("web", "turn-2"),
|
||||
closingBucketOf("web", null, null, listOf("turn-1", "turn-2")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a closing frame never invents a bucket out of nothing`() {
|
||||
assertEquals(TurnBucket("web", ""), closingBucketOf("web", "turn-1", null, emptyList()))
|
||||
}
|
||||
|
||||
// —— 六态判定(规范 §1.1)——
|
||||
|
||||
@Test
|
||||
fun `a local bubble is sending until it is bound or queued`() {
|
||||
assertEquals(
|
||||
TurnPhase.Sending,
|
||||
turnPhaseOf(local = true, binding = null, queued = false, rejected = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queued local bubble is queued not sending`() {
|
||||
assertEquals(
|
||||
TurnPhase.Queued,
|
||||
turnPhaseOf(local = true, binding = null, queued = true, rejected = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a persisted bubble with nothing else is sent`() {
|
||||
assertEquals(
|
||||
TurnPhase.Sent,
|
||||
turnPhaseOf(local = false, binding = null, queued = false, rejected = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a queued persisted bubble is queued and not grey`() {
|
||||
// 规范 §1.3:`queued` **不是**灰态。排队与「已读、未答」是两件事,不许合并。
|
||||
assertEquals(
|
||||
TurnPhase.Queued,
|
||||
turnPhaseOf(local = false, binding = null, queued = true, rejected = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bound message that is not closed is answering`() {
|
||||
assertEquals(
|
||||
TurnPhase.Answering,
|
||||
turnPhaseOf(local = false, binding = binding("turn-1"), queued = false, rejected = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bound message that is closed is answered`() {
|
||||
assertEquals(
|
||||
TurnPhase.Answered,
|
||||
turnPhaseOf(local = false, binding = binding("turn-1").copy(closed = true), queued = false, rejected = false),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a rejection outranks every other signal`() {
|
||||
// ⑥ 是最重的事实:这条根本没进去。再拿「有没有被接手」去描述它就是错的。
|
||||
assertEquals(
|
||||
TurnPhase.Rejected,
|
||||
turnPhaseOf(local = false, binding = binding("turn-1"), queued = true, rejected = true),
|
||||
)
|
||||
assertEquals(
|
||||
TurnPhase.Rejected,
|
||||
turnPhaseOf(local = true, binding = null, queued = false, rejected = true),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unanswered binding beats the queued mark`() {
|
||||
// ③ → ④ 的换态:被接手之后,「排队中」这个角标必须让位给灰态。
|
||||
assertEquals(
|
||||
TurnPhase.Answering,
|
||||
turnPhaseOf(local = false, binding = binding("turn-1"), queued = true, rejected = false),
|
||||
)
|
||||
}
|
||||
|
||||
// —— 灰态收口(规范 §1.2)——
|
||||
|
||||
@Test
|
||||
fun `the matching turn end closes the grey state`() {
|
||||
assertTrue(closesAnswering("turn-1", "turn-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `another turn never closes this one`() {
|
||||
assertFalse(closesAnswering("turn-1", "turn-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two blanks are not a match`() {
|
||||
// 「都为空所以相等」会让老客户端 / 老服务端上的消息被随便一帧误收口——这条是刻意的。
|
||||
assertFalse(closesAnswering(null, null))
|
||||
assertFalse(closesAnswering("", " "))
|
||||
assertFalse(closesAnswering("turn-1", null))
|
||||
assertFalse(closesAnswering(null, "turn-1"))
|
||||
}
|
||||
|
||||
// —— 绑定表 ——
|
||||
|
||||
private fun binding(turnId: String, vararg messageIds: String) =
|
||||
TurnBinding(turnId = turnId, messageIds = messageIds.toList())
|
||||
|
||||
@Test
|
||||
fun `turn begin binds the messages it names`() {
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("turn-1", 1, listOf("m-a"), 1)
|
||||
|
||||
assertEquals(1, turns.size)
|
||||
assertEquals(TurnBinding("turn-1", 1, listOf("m-a"), 1), turns.single())
|
||||
assertEquals("turn-1", turns.bindingOfMessage("m-a")?.turnId)
|
||||
assertNull(turns.bindingOfMessage("m-b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `two legs keep their own messages`() {
|
||||
val turns = emptyList<TurnBinding>()
|
||||
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
|
||||
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
|
||||
|
||||
assertEquals(listOf("turn-1", "turn-2"), turns.map { it.turnId })
|
||||
assertEquals("turn-1", turns.bindingOfMessage("m-a")?.turnId)
|
||||
assertEquals("turn-2", turns.bindingOfMessage("m-b")?.turnId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a repeated turn begin merges instead of replacing`() {
|
||||
val turns = emptyList<TurnBinding>()
|
||||
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
|
||||
.withTurnBegin("turn-1", 1, listOf("m-b"), 1)
|
||||
|
||||
assertEquals(listOf("m-a", "m-b"), turns.single().messageIds)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a repeated turn begin never reopens a closed leg`() {
|
||||
// 重开会让气泡回到灰态,而第二次收口永远不会再来(那一帧已经发过了)。
|
||||
val closed = emptyList<TurnBinding>()
|
||||
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
|
||||
.closingTurn("turn-1")
|
||||
|
||||
val again = closed.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
|
||||
|
||||
assertTrue(again.single().closed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank turnId in turn begin changes nothing`() {
|
||||
val turns = emptyList<TurnBinding>()
|
||||
|
||||
assertSame(turns, turns.withTurnBegin("", 1, listOf("m-a"), 1))
|
||||
assertSame(turns, turns.withTurnBegin(" ", 1, listOf("m-a"), 1))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steer ack binds a message to an existing leg`() {
|
||||
val turns = emptyList<TurnBinding>()
|
||||
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
|
||||
.withSteeredMessage("turn-1", "m-b")
|
||||
|
||||
assertEquals(listOf("m-a", "m-b"), turns.single().messageIds)
|
||||
assertEquals("turn-1", turns.bindingOfMessage("m-b")?.turnId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steer ack creates a leg when the turn is unknown`() {
|
||||
// 服务端可能先发 steer_ack 再补 turn_begin;先建一条只有这一条消息的表,之后合并即可。
|
||||
val turns = emptyList<TurnBinding>().withSteeredMessage("turn-1", "m-b")
|
||||
|
||||
assertEquals(TurnBinding("turn-1", UNKNOWN_LEG, listOf("m-b")), turns.single())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steer ack with a blank turnId binds nothing`() {
|
||||
val turns = listOf(binding("turn-1", "m-a"))
|
||||
|
||||
assertSame(turns, turns.withSteeredMessage("", "m-b"))
|
||||
assertSame(turns, turns.withSteeredMessage("turn-1", " "))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing one turn leaves the other leg open`() {
|
||||
val turns = emptyList<TurnBinding>()
|
||||
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
|
||||
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
|
||||
.closingTurn("turn-2")
|
||||
|
||||
assertEquals(TurnPhase.Answering, phaseOfMessage(turns, "m-a"))
|
||||
assertEquals(TurnPhase.Answered, phaseOfMessage(turns, "m-b"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing with an unknown id closes nothing and returns the same instance`() {
|
||||
val turns = listOf(binding("turn-1", "m-a"))
|
||||
|
||||
assertSame(turns, turns.closingTurn(null))
|
||||
assertSame(turns, turns.closingTurn(""))
|
||||
assertSame(turns, turns.closingTurn("turn-9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `closing twice is the same as closing once`() {
|
||||
val once = listOf(binding("turn-1", "m-a")).closingTurn("turn-1")
|
||||
|
||||
assertSame(once, once.closingTurn("turn-1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all closed closes every open leg and is idempotent`() {
|
||||
val turns = emptyList<TurnBinding>()
|
||||
.withTurnBegin("turn-1", 1, listOf("m-a"), 1)
|
||||
.withTurnBegin("turn-2", 2, listOf("m-b"), 2)
|
||||
|
||||
val closed = turns.allClosed()
|
||||
|
||||
assertTrue(closed.all { it.closed })
|
||||
assertSame(closed, closed.allClosed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank message id never matches a binding`() {
|
||||
val turns = listOf(binding("turn-1", ""))
|
||||
|
||||
assertNull(turns.bindingOfMessage(""))
|
||||
assertNull(turns.bindingOfMessage(" "))
|
||||
}
|
||||
|
||||
// —— 路小标 ——
|
||||
|
||||
@Test
|
||||
fun `only the second leg and later get a badge`() {
|
||||
// 单路是常态:给唯一一路挂「并行路 1」只是给每条回复加一个新的视觉噪音。
|
||||
assertNull(legBadgeOf(UNKNOWN_LEG))
|
||||
assertNull(legBadgeOf(1))
|
||||
assertEquals("并行路 2", legBadgeOf(2))
|
||||
assertEquals("并行路 3", legBadgeOf(3))
|
||||
}
|
||||
|
||||
private fun phaseOfMessage(turns: List<TurnBinding>, messageId: String): TurnPhase =
|
||||
turnPhaseOf(
|
||||
local = false,
|
||||
binding = turns.bindingOfMessage(messageId),
|
||||
queued = false,
|
||||
rejected = false,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.data.api.dto.HistoryMessageDto
|
||||
import top.yeij.cyrene.domain.chat.TurnBinding
|
||||
import top.yeij.cyrene.domain.chat.TurnBucket
|
||||
import top.yeij.cyrene.domain.chat.TurnPhase
|
||||
import top.yeij.cyrene.domain.chat.withTurnBegin
|
||||
import top.yeij.cyrene.domain.parse.ParsedBubble
|
||||
|
||||
/**
|
||||
* 流式气泡的**两级分桶**(规范 §1.4 / §1.7 第 1 条)在 UI 层的那一半。
|
||||
*
|
||||
* 这里的头号回归是「两路交错的 delta 不许糊成一条」——老代码只有一个 `StreamingState`,
|
||||
* 所有 `message.delta` 都往它上面堆;分桶之后每一路各有一份。第二件事是**单路逐字不变**:
|
||||
* 帧壳不带 `turnId` 时全部帧落匿名桶,`streams` 的长度恒为 1。
|
||||
*/
|
||||
class TurnStreamsTest {
|
||||
|
||||
private fun bucket(turnId: String) = TurnBucket("web", turnId)
|
||||
|
||||
private fun bubble(text: String) =
|
||||
ParsedBubble(body = text, parts = emptyList(), head = emptyList(), tail = emptyList())
|
||||
|
||||
private fun streaming(vararg texts: String) = StreamingState(bubbles = texts.map(::bubble))
|
||||
|
||||
private fun leg(turnId: String, vararg texts: String) = StreamingLeg(bucket(turnId), streaming(*texts))
|
||||
|
||||
// —— 两路各长各的 ——
|
||||
|
||||
@Test
|
||||
fun `two turns accumulate in their own legs`() {
|
||||
val legs = emptyList<StreamingLeg>()
|
||||
.withLeg(bucket("t1")) { it.copy(bubbles = listOf(bubble("第一路"))) }
|
||||
.withLeg(bucket("t2")) { it.copy(bubbles = listOf(bubble("第二路"))) }
|
||||
|
||||
assertEquals(2, legs.size)
|
||||
assertEquals(listOf("第一路"), bodies(legs, "t1"))
|
||||
assertEquals(listOf("第二路"), bodies(legs, "t2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `interleaved deltas never merge into one leg`() {
|
||||
// 规范 §1.4 点名的那个错:按「当前气泡」累加,两条回复会被糊成一条。
|
||||
val legs = emptyList<StreamingLeg>()
|
||||
.withLeg(bucket("t1")) { it.copy(bubbles = it.bubbles + bubble("A1")) }
|
||||
.withLeg(bucket("t2")) { it.copy(bubbles = it.bubbles + bubble("B1")) }
|
||||
.withLeg(bucket("t1")) { it.copy(bubbles = it.bubbles + bubble("A2")) }
|
||||
.withLeg(bucket("t2")) { it.copy(bubbles = it.bubbles + bubble("B2")) }
|
||||
|
||||
assertEquals(listOf("A1", "A2"), bodies(legs, "t1"))
|
||||
assertEquals(listOf("B1", "B2"), bodies(legs, "t2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one turn is exactly one leg`() {
|
||||
// 「字段缺失 ⇒ 行为与今天逐字相同」在 UI 层的判据:单路时长度恒为 1。
|
||||
val legs = emptyList<StreamingLeg>()
|
||||
.withLeg(bucket("")) { it.copy(bubbles = listOf(bubble("A"))) }
|
||||
.withLeg(bucket("")) { it.copy(bubbles = it.bubbles + bubble("B")) }
|
||||
|
||||
assertEquals(1, legs.size)
|
||||
assertEquals(listOf("A", "B"), bodies(legs, ""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legs keep their creation order`() {
|
||||
val legs = emptyList<StreamingLeg>()
|
||||
.withLeg(bucket("t1")) { it }
|
||||
.withLeg(bucket("t2")) { it }
|
||||
.withLeg(bucket("t1")) { it }
|
||||
|
||||
assertEquals(listOf("t1", "t2"), legs.map { it.bucket.turnId })
|
||||
}
|
||||
|
||||
// —— 新一轮开始时的收口 ——
|
||||
|
||||
@Test
|
||||
fun `beginning a turn drops the other finished legs`() {
|
||||
// 老代码的 freshTurnIfFinished:上一轮已结束就把那一个气泡整个清掉(内容已被落库行取代)。
|
||||
val legs = listOf(
|
||||
StreamingLeg(bucket("t1"), streaming("旧内容").copy(active = false)),
|
||||
StreamingLeg(bucket("t2"), streaming("还在跑")),
|
||||
)
|
||||
|
||||
val after = legs.beginLeg(bucket("t2"))
|
||||
|
||||
assertEquals(listOf("t2"), after.map { it.bucket.turnId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beginning a turn never touches a leg that is still running`() {
|
||||
val legs = listOf(
|
||||
StreamingLeg(bucket("t1"), streaming("另一路")),
|
||||
StreamingLeg(bucket("t2"), streaming("旧").copy(active = false)),
|
||||
)
|
||||
|
||||
val after = legs.beginLeg(bucket("t2"))
|
||||
|
||||
assertEquals(listOf("t1", "t2"), after.map { it.bucket.turnId })
|
||||
assertEquals(listOf("另一路"), bodies(after, "t1"))
|
||||
assertEquals(emptyList<String>(), bodies(after, "t2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `beginning a turn on an unknown bucket adds nothing`() {
|
||||
// 新的桶由紧接着的 withLeg 建;这里凭空塞一个空态只会在坏时序下多出一个看不见的空气泡。
|
||||
val legs = listOf(leg("t1", "内容"))
|
||||
|
||||
assertEquals(legs, legs.beginLeg(bucket("t9")))
|
||||
}
|
||||
|
||||
// —— 收口 ——
|
||||
|
||||
@Test
|
||||
fun `finishing a leg keeps its text and stops the cursor`() {
|
||||
val legs = listOf(StreamingLeg(bucket("t1"), streaming("半句")))
|
||||
|
||||
val after = legs.finishingLeg(bucket("t1"), listOf(bubble("整句")))
|
||||
|
||||
assertEquals(listOf("整句"), bodies(after, "t1"))
|
||||
assertTrue(!after.single().state.active)
|
||||
assertTrue(!after.single().state.thinkingActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finishing without a parser keeps the text that is already on screen`() {
|
||||
// 「没有新正文」不该被写成「正文是空的」——那会把已经显示出来的字擦掉。
|
||||
val legs = listOf(StreamingLeg(bucket("t1"), streaming("已经显示的")))
|
||||
|
||||
val after = legs.finishingLeg(bucket("t1"), null)
|
||||
|
||||
assertEquals(listOf("已经显示的"), bodies(after, "t1"))
|
||||
assertTrue(!after.single().state.active)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `finishing an unknown leg creates nothing`() {
|
||||
val legs = listOf(leg("t1", "内容"))
|
||||
|
||||
assertSame(legs, legs.finishingLeg(bucket("t9"), emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dropping the persisted leg keeps the other one`() {
|
||||
val legs = listOf(leg("t1", "A"), leg("t2", "B"))
|
||||
|
||||
val after = legs.withoutLeg(bucket("t1"))
|
||||
|
||||
assertEquals(listOf("t2"), after.map { it.bucket.turnId })
|
||||
assertSame(after, after.withoutLeg(bucket("t1")))
|
||||
}
|
||||
|
||||
// —— 六态在状态上的推导 ——
|
||||
|
||||
@Test
|
||||
fun `without any turn frames every message is where it is today`() {
|
||||
// 老服务端(没有 turn_begin / steer_ack.turnId):已落库 = ②,本地气泡 = ①。
|
||||
val state = ChatUiState(messages = listOf(message("m-1")))
|
||||
|
||||
assertEquals(TurnPhase.Sent, state.phaseOf(state.messages.single()))
|
||||
assertEquals(TurnPhase.Sending, state.phaseOf(PendingMessage("local-1", "你好")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a bound message is grey until its turn closes`() {
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 2, listOf("m-1"), 2)
|
||||
val bound = ChatUiState(messages = listOf(message("m-1")), turns = turns)
|
||||
|
||||
assertEquals(TurnPhase.Answering, bound.phaseOf(bound.messages.single()))
|
||||
assertTrue(bound.phaseOf(bound.messages.single()) != TurnPhase.Queued)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the queued mark is found through the client message id too`() {
|
||||
// 队列回执先到、落库后到:那时本地认识的键是 `localId`,而消息上的键是 `clientMsgId`。
|
||||
val state = ChatUiState(
|
||||
messages = listOf(message("m-1", clientMsgId = "local-1")),
|
||||
queuedKeys = setOf("local-1"),
|
||||
)
|
||||
|
||||
assertEquals(TurnPhase.Queued, state.phaseOf(state.messages.single()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the queued mark on a local bubble survives into the composer view`() {
|
||||
val state = ChatUiState(pending = listOf(PendingMessage("local-1", "你好")), queuedKeys = setOf("local-1"))
|
||||
|
||||
assertEquals(TurnPhase.Queued, state.phaseOf(state.pending.single()))
|
||||
}
|
||||
|
||||
// —— 路小标与跳转 ——
|
||||
|
||||
@Test
|
||||
fun `a single leg carries no badge`() {
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-1"), 1)
|
||||
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
|
||||
|
||||
assertNull(state.legBadgeForMessage(state.messages.single()))
|
||||
assertNull(state.legBadgeForTurn("t1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the second leg carries its badge on the question and on the stream`() {
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2)
|
||||
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
|
||||
|
||||
assertEquals("并行路 2", state.legBadgeForMessage(state.messages.single()))
|
||||
assertEquals("并行路 2", state.legBadgeForTurn("t2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the answer of a leg carries the same badge through its own turn id`() {
|
||||
// 回复侧:历史行的可选 turnId(规范 §2.7)指出它是哪一路产出的 ⇒ 落库之后小标仍在。
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2)
|
||||
val answer = message("m-9", role = "assistant", turnId = "t2")
|
||||
val state = ChatUiState(messages = listOf(message("m-1"), answer), turns = turns)
|
||||
|
||||
assertEquals("并行路 2", state.legBadgeForMessage(answer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an old answer row without a turn id carries no badge`() {
|
||||
// v0.65 之前历史行没有 turnId ⇒ 挂不出标,也就一个像素都不多画。
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t2", 2, listOf("m-1"), 2)
|
||||
val answer = message("m-9", role = "assistant")
|
||||
val state = ChatUiState(messages = listOf(message("m-1"), answer), turns = turns)
|
||||
|
||||
assertNull(state.legBadgeForMessage(answer))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jumping to a question maps to the reverse layout index`() {
|
||||
// items = [已落库 m-1, m-2, m-3](升序);reverseLayout 下懒下标 = size - 1 - 升序下标。
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-1"), 1)
|
||||
val state = ChatUiState(
|
||||
messages = listOf(message("m-1"), message("m-2"), message("m-3")),
|
||||
turns = turns,
|
||||
)
|
||||
|
||||
assertEquals(2, state.jumpIndexForTurn("t1"))
|
||||
assertNull(state.jumpIndexForTurn("t9"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a turn whose question is not loaded has no jump target`() {
|
||||
val turns = emptyList<TurnBinding>().withTurnBegin("t1", 1, listOf("m-unknown"), 1)
|
||||
val state = ChatUiState(messages = listOf(message("m-1")), turns = turns)
|
||||
|
||||
assertNull(state.jumpIndexForTurn("t1"))
|
||||
}
|
||||
|
||||
// —— 气泡角标文案 ——
|
||||
|
||||
@Test
|
||||
fun `the footer only mentions queued and answering`() {
|
||||
assertEquals("来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Sent, null))
|
||||
assertEquals("排队中 · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Queued, null))
|
||||
assertEquals("已读,正在回答… · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Answering, null))
|
||||
assertEquals("并行路 2 · 来自 安卓", bubbleFooter("来自 安卓", TurnPhase.Answered, "并行路 2"))
|
||||
assertNull(bubbleFooter(null, TurnPhase.Sent, null))
|
||||
}
|
||||
|
||||
private fun bodies(legs: List<StreamingLeg>, turnId: String): List<String> =
|
||||
legs.leg(bucket(turnId))?.state?.bubbles?.map { it.body }.orEmpty()
|
||||
|
||||
private fun message(id: String, role: String = "user", clientMsgId: String? = null, turnId: String? = null) =
|
||||
HistoryMessageDto(
|
||||
id = id,
|
||||
sessionId = "web",
|
||||
role = role,
|
||||
content = id,
|
||||
clientMsgId = clientMsgId,
|
||||
createdAt = 1L,
|
||||
turnId = turnId,
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user