merge: W52B —— 抽屉能看出哪个会话正在跑(S9)+ 行内「停止」入口(S5)
This commit is contained in:
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user