merge: W52B —— 抽屉能看出哪个会话正在跑(S9)+ 行内「停止」入口(S5)

This commit is contained in:
AskaEth committed 2026-10-06 15:56:23 +08:00
commit 37289391f8
6 files changed
+452 -15

No files matched your search

@@ -0,0 +1,200 @@
package top.yeij.cyrene.ui.sessions
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.data.ws.WsEvent
/**
* S9「这个会话正在生成」那一套纯逻辑:账本的折帧规则,以及「停止」帧的 scope。
*
* 这一批全部是**放在 JVM 上能断言的**那一半——屏幕上到底长什么样(第三行、按钮挤不挤得下)
* 只有单测证明不了,报告里如实标了。
*
* 两组纪律在每一条里都能看到:
* 1. **只有活帧能点亮**(`session_busy` / `turn_begin`),握手不回放 ⇒ 没收到帧的会话**一定**不显示;
* 2. **收尾宁可少显示**:拿不准(`turn.end` 认不得那一路、并行的另一路还在跑)就继续亮着,
* 但绝不在「其实已经闲了」时还亮着——那是这个功能唯一会骗到人的方向。
*/
class SessionBusyTest {
private fun busy(sessionId: String) = mapOf(sessionId to emptySet<String>())
// —— 点亮 ——
@Test
fun `an empty ledger means nothing is shown`() {
// 冷启动、握手之后:服务端不回放该状态,本端什么都不知道 ⇒ 抽屉里不显示「正在生成」。
val after = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.SessionRead(sessionId = "web", readAt = 1L))
.applyBusyFrame(WsEvent.MessagePersisted(message = null, sessionId = "web"))
assertTrue(after.isEmpty())
}
@Test
fun `session_busy lights up its own session`() {
val after = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.SessionBusy(sessionId = "web:1", mode = "thinking"))
assertTrue("web:1" in after)
}
@Test
fun `session_busy for a session does not touch its neighbours`() {
val after = busy("web").applyBusyFrame(WsEvent.SessionBusy(sessionId = "web:1", mode = "executing"))
assertTrue("web" in after)
assertTrue("web:1" in after)
assertEquals(2, after.size)
}
@Test
fun `turn_begin is live evidence too`() {
// 它与 session_busy 同源(服务端启动一路时两条一起发),但更频繁:session_busy 那一帧
// 若因重连错过,下一路起来时灯还能亮回来。
val after = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.TurnBegin(sessionId = "web:1", turnId = "t1"))
assertTrue("web:1" in after)
assertEquals(setOf("t1"), after["web:1"])
}
@Test
fun `a blank session id is a bad frame and changes nothing`() {
// 空白 = 不知道说的是谁(与 onSessionRead 同一条口径):既不点亮,也不误灭别人。
val after = busy("web").applyBusyFrame(WsEvent.SessionBusy(sessionId = "", mode = "thinking"))
assertEquals(setOf("web"), after.keys)
}
@Test
fun `repeating a frame is idempotent`() {
val frame = WsEvent.SessionBusy(sessionId = "web", mode = "thinking")
val once = emptyMap<String, Set<String>>().applyBusyFrame(frame)
assertEquals(once, once.applyBusyFrame(frame))
}
// —— 收尾 ——
@Test
fun `session_idle always clears`() {
// 规范 §1.5:它只在**全部**在途路收尾且 pending 空时才发 ⇒ 到了就是真的闲了。
val after = busy("web").applyBusyFrame(WsEvent.SessionIdle(sessionId = "web", mode = "idle"))
assertTrue(after.isEmpty())
}
@Test
fun `turn_end with no matching turn_begin changes nothing`() {
// 本端中途才接上(没见过这一路的 turn_begin):拿别人家的收尾去灭灯,会把还在跑的那一路
// 报成空闲。宁可继续亮着,等 session_idle 这个权威口径。
val after = busy("web").applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t-unknown"))
assertTrue("web" in after)
}
@Test
fun `turn_end retires the leg it knows about`() {
val after = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t1"))
.applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t1"))
assertTrue(after.isEmpty())
}
@Test
fun `two parallel legs need both turn_end frames to clear`() {
// 忙时并行规范 §2.3:先收尾的那一路不是「这个会话闲了」。
val started = emptyMap<String, Set<String>>()
.applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t1"))
.applyBusyFrame(WsEvent.TurnBegin(sessionId = "web", turnId = "t2"))
val afterFirst = started.applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t1"))
val afterSecond = afterFirst.applyBusyFrame(WsEvent.TurnEnd(sessionId = "web", turnId = "t2"))
assertTrue("t2 还在跑,灯不能灭", "web" in afterFirst)
assertTrue(afterSecond.isEmpty())
}
@Test
fun `an interrupt that leaves other legs running keeps the light on`() {
// `scope:"current"` 只取消在跑的那一路、别的路照跑。无条件收掉就会在并行时显示「已经停了」
// ——那正是 v2 前端审计记成「会错」的那条(前端同步缺口 A7)。
val after = busy("web").applyBusyFrame(
WsEvent.SessionInterrupted(sessionId = "web", mode = "executing", dropped = 0, activeTurns = 1),
)
assertTrue("web" in after)
}
@Test
fun `an interrupt with nothing left clears`() {
val after = busy("web").applyBusyFrame(
WsEvent.SessionInterrupted(sessionId = "web", mode = "idle", dropped = 0, activeTurns = 0),
)
assertTrue(after.isEmpty())
}
@Test
fun `an old server without activeTurns also clears on interrupt`() {
// `activeTurns` 是忙时并行规范(v0.65)才加的键。键缺席的那一版根本没有「另一路」这回事,
// 单路被打断就是真的闲了 ⇒ 收掉。
val after = busy("web").applyBusyFrame(
WsEvent.SessionInterrupted(sessionId = "web", mode = "idle", dropped = 0),
)
assertTrue(after.isEmpty())
}
@Test
fun `stream_end never clears`() {
// 它只结束某一次模型流(一轮里有多个正文段、工具循环里还有多次),结束整轮的是 turn.end。
// 按它灭灯就是 A7 那类错误的另一个入口。
val after = busy("web").applyBusyFrame(
WsEvent.StreamEnd(stopReason = "stop", sessionId = "web", turnId = "t1"),
)
assertTrue("web" in after)
}
@Test
fun `an unrelated frame keeps the very same ledger`() {
// 流式那一串是刷新率级的。无关帧必须原样返回**同一张表**,否则 `SessionsViewModel` 会为
// 每一帧 delta 重新分配、重推一次状态(那正是「每帧都在重组抽屉」的写法)。
val ledger = busy("web")
val after = ledger.applyBusyFrame(WsEvent.MessageDelta(delta = "一", index = 0, sessionId = "web"))
assertSame(ledger, after)
}
// —— 文案与「停止」帧的 scope ——
@Test
fun `the busy label says what the chat page says`() {
// 同一个服务端帧(忙)说的是同一件事,两处各写一句就会各自漂移;顺带钉住它不带「…」以外的
// 编排细节(mode 不参与措辞)。
assertEquals("正在思考…", SESSION_BUSY_LABEL)
assertEquals("停止", SESSION_STOP_LABEL)
}
@Test
fun `the stop frame keeps the client-only-current invariant`() {
// 本端**从不发 `all`**(devdocs/02 A2 补记、devdocs/03 §4):它连待处理队列一起清掉,而
// 「已丢弃 N 条」那句话在本端没有落点。所以这个常量必须与「打断」档同一个取值。
assertEquals(InterruptMode.Interrupt.wireScope, STOP_GENERATING_SCOPE)
assertFalse("空串在服务端也是 `all`,绝不能滑到那一档", STOP_GENERATING_SCOPE.isBlank())
}
@Test
fun `the ui state ships knowing nothing`() {
assertTrue(SessionsUiState().busySessions.isEmpty())
}
}