feat(settings): 设置页「她忙时发消息」追加第三档「并行」——忙时不打断、另起一路(chat.parallel=true)

主人要求:在既有的「追加队列 / 打断」之下再追加一档「并行」。三档语义互补——追加队列进队列等
(默认档,逐字节不变)、打断先发 interrupt 再发这条、并行不打断、另起一路同时处理。

改了什么:
- AppSettings:InterruptMode 加第三个值 Parallel(wireScope = null, wireParallel = true),并给枚举
  补出第二根线 wireParallel(wireScope 只管 interrupt 的 scope,表达不了「要声明并行」这件事)。
  既有两个值的 name 与落盘键 interrupt_mode 一个字未动——改名会让已存偏好读不出来并静默回落默认档。
  落盘字符串 → 档位的映射抽成顶层纯函数 toInterruptMode(AppSettings 本体要 Android + DataStore,
  单测里起不来),新增的第三个名字能正确读回,未知字符串仍回落 AppendQueue(既有行为未变)。
- 判据抽成纯函数 parallelForSend(mode, busy) 与 parallelWireValue(declare):前者与
  interruptScopeForSend 共用同一个忙判据(ChatUiState.busyMode != null),只在会话正忙时为真。
  规范 §2.2 没写空闲时要不要也声明——空闲时这条本来就是它自己的回合,声明与不声明在服务端是同一
  件事,故取与打断同一个判据(最小惊讶:空闲时三档逐字节完全相同)。
- 两条互斥由枚举取值本身保证,不是靠约定:并行档 wireScope 是 null ⇒ 绝不发 interrupt;另外两档
  wireParallel 是 false ⇒ 绝不带 parallel 键。默认档「追加队列」线上零差异由此结构性成立。
- 发送路径接线:ChatViewModel.deliver 只读一次偏好、同时喂给两个判据(读两次会在用户切档的瞬间
  出现「既打断又并行」);ChatSocket.sendChat 与 ChatRequest 各加一个 parallel 参数,false 一律
  翻成整键省略——显式 false 会被序列化成 parallel:false,那是「显式否认」而不是「未声明」,
  默认档就不再逐字节不变。
- 设置页:新选项标题「并行」,summary 说清后果——不打断她正在说的这一句、两路各算一遍会多花一份
  算力与 token(主人裁决:峰值 token 近翻倍可接受,但用户有权知道)。UI 仍是 Miuix,与同栏另外
  两条逐字同形,勾选标记位置相同。
- 分享路径同样不套用这一档偏好(W26 的 allowInterrupt 因此更名为 applySendPreference,它现在管
  的是整档偏好):分享是批量投递,逐条索要并行会被服务端 2 路额度把同一次分享劈成两种待遇,且每
  条各算一遍;用户也没按过发送键说这条要并行。单张图时同样不声明,规则才能一句话说清。

为什么:主人 2026-10-05 的裁决(真两路并行)需要客户端有一个显式声明入口;W30 已备好 chat.parallel
的线上形状(默认 null = 整键省略),本次只把设置页这一档与发送路径接通,上行仍是纯增量——没选
「并行」的用户线上一个字节都不变。

验收:assembleDebug 通过;testDebugUnitTest 通过(1022 测试 / 0 失败 / 0 错误;基线 1004,新增 18:
ParallelForSendTest 13 条 + InterruptScopeTest 1 条 + InterruptModeTest 4 条)。
This commit is contained in:
AskaEth committed 2026-10-05 09:37:26 +08:00
1 parent 82d3ca3953
commit a776e9f608
11 files changed
+462 -92

No files matched your search

@@ -1,16 +1,24 @@
package top.yeij.cyrene.core.prefs
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import top.yeij.cyrene.data.ws.WsOutbound
/**
* A2 偏好 → 线上取值 的映射(契约 v0.56 §3.1)。
* 「她忙时发消息」档位 → 线上取值 的映射(契约 v0.56 §3.1 的 `interrupt.scope`;忙时并行规范
* §2.2 的 `chat.parallel`)。
*
* 这一层只负责一件事:把用户选的档位翻成 `interrupt` 帧的 `scope`。默认档必须翻成
* **整键省略**——服务端视同 `"all"`,帧因此与加入 `scope` 之前**逐字节相同**(兼容保证)。
* 这一层负责三件事:
* 1. 把用户选的档位翻成 `interrupt` 帧的 `scope`——**默认档与「并行」档都必须是「不发」**
* (默认档在服务端视同 `"all"`,帧因此与加入 `scope` 之前逐字节相同;并行档则压根不该有这条帧,
* 它与打断是互斥语义);
* 2. 两个声明**没有哪一档同时给**(`wireScope` 与 `wireParallel` 永不同时成立);
* 3. 落盘那一侧(`AppSettings` 的 `interrupt_mode` 键,按 `enum.name` 存)三档都能存能读、
* 未知值仍回落默认档。`AppSettings` 本体要 Android + DataStore、单测里起不来,映射本身是顶层
* 纯函数 [toInterruptMode]。
*/
class InterruptModeTest {
@@ -22,7 +30,7 @@ class InterruptModeTest {
val mode = AppPrefs().interruptMode
assertEquals(InterruptMode.AppendQueue, mode)
assertNull("默认档必须是「省略 scope」(服务端 = all)", mode.wireScope)
assertNull("默认档必须是「不发打断帧」", mode.wireScope)
assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(mode))
}
@@ -36,23 +44,63 @@ class InterruptModeTest {
}
@Test
fun `the preference only ever produces the two documented wire values`() {
// 服务端取值域是 {"", "all", "current"};客户端只发「省略」与 "current" 两种,
// ""/"all" 已由省略表达——不自己补一串等价别名,少一种线上形状就少一类歧义。
assertEquals(listOf(null, "current"), InterruptMode.entries.map { it.wireScope })
fun `the parallel mode sends no interrupt frame at all`() {
// 并行与打断互斥:并行档先打断就恰好砍掉它要并行的那一路。所以这里不是「省略 scope」
// 而是「这条路根本不发这条帧」——`wireScope` 就是 `null`,帧形状与默认档那个「不发」一致。
assertNull(InterruptMode.Parallel.wireScope)
assertEquals("""{"type":"interrupt","data":{"sessionId":"web"}}""", frameOf(InterruptMode.Parallel))
}
@Test
fun `the preference only ever produces the documented interrupt wire values`() {
// 服务端取值域是 {"", "all", "current"};客户端只发「不发打断帧」与 "current" 两种,
// ""/"all" 由「不发打断帧」表达——不自己补一串等价别名,少一种线上形状就少一类歧义。
assertEquals(listOf(null, "current", null), InterruptMode.entries.map { it.wireScope })
assertTrue(InterruptMode.entries.none { it.wireScope == "all" })
assertTrue(InterruptMode.entries.none { it.wireScope == "" })
}
@Test
fun `both modes stay distinguishable on the wire`() {
// 打成同一个形状就等于「两种模式被当成一种」——正是 A2 要修掉的那类缺陷。
assertTrue(frameOf(InterruptMode.AppendQueue) != frameOf(InterruptMode.Interrupt))
fun `no mode ever asks for both an interrupt and a parallel leg`() {
// 一对互斥语义:打断 = 先取消她正在说的这一句;并行 = 不打断、另起一路同时说。
// 两件事同时声明是自相矛盾的(先砍掉要并行的那一路),三档各占一种组合,没有第三种。
val names = InterruptMode.entries.map { it.name }
assertTrue(
"没有任何一档能同时给两个声明:$names",
InterruptMode.entries.none { it.wireScope != null && it.wireParallel },
)
}
@Test
fun `only the parallel mode ever declares a parallel leg`() {
assertEquals(listOf(InterruptMode.Parallel), InterruptMode.entries.filter { it.wireParallel })
assertFalse("默认档一个声明都不发", AppPrefs().interruptMode.wireParallel)
}
@Test
fun `the persisted enum names are stable`() {
// AppSettings 按 enum 名落盘:改名 = 把所有已存偏好静默重置回默认档。
assertEquals(listOf("AppendQueue", "Interrupt"), InterruptMode.entries.map { it.name })
// 第三档是**追加**上来的新名字;既有那两个名字一个字都不许动。
assertEquals(listOf("AppendQueue", "Interrupt", "Parallel"), InterruptMode.entries.map { it.name })
}
@Test
fun `every mode survives a round trip through the persisted name`() {
InterruptMode.entries.forEach { mode ->
assertEquals("落盘名 ${mode.name} 读不回原来的档", mode, mode.name.toInterruptMode())
}
// 三个落盘名必须互不相同,否则有档位会被读成另一档。
assertEquals(3, InterruptMode.entries.map { it.name }.toSet().size)
}
@Test
fun `an unknown persisted value still falls back to the default mode`() {
// 缺省 / 旧版本遗留 / 被手改过 / 大小写不同 / 带了空格的值,一律解成默认档:回落方向必须是
// 「最少动作」的那一档,**绝不**悄悄换成更激进的「打断」或「并行」。
// 注意 "current" 是 interrupt 帧的 scope 取值、不是档位名——它也必须回落。
listOf<String?>(null, "", "all", "current", "Parallelism", "parallel", "AppendQueue ", "3").forEach { raw ->
assertEquals("落盘值 ${raw?.let { "「$it」" } ?: "空"} 应回落默认档", InterruptMode.AppendQueue, raw.toInterruptMode())
}
}
}
@@ -7,9 +7,10 @@ import org.junit.Test
/**
* 忙着并行规范的上行两处**纯增量**(§2.2 `chat.parallel` / §2.3 `interrupt.turnId`)。
*
* 本层只负责一件事:**省略时整键不进线上、给值时原样发出**。本端今天一个字节都不发这两个字段
* (真并行要用户显式声明,声明入口不在本次范围内),所以这一组测试真正钉住的是
* 「老形状的那条帧仍然逐字节相同」——那是与老服务端兼容的全部依据。
* 本层只负责一件事:**省略时整键不进线上、给值时原样发出**。`parallel` 现在有调用方了——
* 设置页那栏选「并行」且会话正忙时由发送路径置 `true`(判据是纯函数 `parallelForSend`,线上取值
* 经 `parallelWireValue` 翻,`false` 一律翻成「整键省略」);`interrupt.turnId` 本端仍一个字节都不发。
* 所以这一组测试真正钉住的是「老形状的那条帧仍然逐字节相同」——那是与老服务端兼容的全部依据。
*/
class WsOutboundParallelTest {
@@ -11,7 +11,8 @@ import top.yeij.cyrene.core.prefs.InterruptMode
* A2 客户端半边:**发这条消息之前要不要先打断**(契约 v0.56 §3.1 的 `interrupt.scope`)。
*
* 这个判据此前**根本不存在**——设置页那个开关没有任何发送路径读它,于是「打断」与「追加队列」
* 行为完全一样,`ChatSocket.sendInterrupt` 一条帧都没发过。这里把它钉成纯函数,四条一次钉全:
* 行为完全一样,`ChatSocket.sendInterrupt` 一条帧都没发过。这里把它钉成纯函数,六条一次钉全
* (并行那一档的**另一半**——`chat.parallel`——见 [ParallelForSendTest]):
*
* | 档位 | 会话正忙 | 该发什么 |
* |---|---|---|
@@ -19,12 +20,16 @@ import top.yeij.cyrene.core.prefs.InterruptMode
* | 追加队列(默认) | 空闲 | **什么都不发** |
* | 打断 | 忙 | `interrupt{scope:"current"}` |
* | 打断 | 空闲 | **什么都不发** |
* | 并行 | 忙 | **什么都不发**(它与打断互斥;那一档声明的是 `chat.parallel`) |
* | 并行 | 空闲 | **什么都不发** |
*
* 两条最容易写错的边界各占一列:
* 三条最容易写错的边界各占一列:
* - **默认档不许有任何行为变化**(第一行、第二行):线上请求必须与接入本判据之前逐字节相同;
* - **打断但空闲不发帧**(第四行):命中空闲会话在服务端是 no-op,白发一条帧只是多一次往返、
* 还会在 logcat 里制造「这里打断过」的假象——所以「正忙」必须是**服务端说的**
* (`session_busy` ⇒ `ChatUiState.busyMode != null`),不是队列深度非零推出来的。
* (`session_busy` ⇒ `ChatUiState.busyMode != null`),不是队列深度非零推出来的;
* - **并行档整列都不发**(第五行、第六行):并行与打断互斥,先打断就恰好砍掉要并行的那一路。
* 这不是「这一档碰巧没得发」,是 `Parallel.wireScope == null` 的结构性结论。
*/
class InterruptScopeTest {
@@ -52,8 +57,8 @@ class InterruptScopeTest {
}
@Test
fun `the whole matrix of both modes against both busy states is pinned`() {
// 2 档 × 2 忙闲 = 4 格,一次穷举钉死;将来任何人动这个判据,这里先红。
fun `the whole matrix of all three modes against both busy states is pinned`() {
// 3 档 × 2 忙闲 = 6 格,一次穷举钉死;将来任何人动这个判据,这里先红。
val matrix = InterruptMode.entries.flatMap { mode ->
listOf(true, false).map { busy ->
"${mode.name}/busy=$busy" to interruptScopeForSend(mode, busy)
@@ -66,6 +71,8 @@ class InterruptScopeTest {
"AppendQueue/busy=false" to null,
"Interrupt/busy=true" to "current",
"Interrupt/busy=false" to null,
"Parallel/busy=true" to null,
"Parallel/busy=false" to null,
),
matrix,
)
@@ -85,7 +92,7 @@ class InterruptScopeTest {
// 发送路径取偏好读失败时回落 `AppPrefs()` 的缺省值(见 ChatViewModel.currentInterruptMode 的 KDoc)。
// 这里钉住那个缺省值本身是**追加队列** = 不发任何帧 —— 读不到偏好时回落的方向必须是
// 「最少动作」的那一档,绝不能悄悄变成更激进的「打断」。
// (落盘字符串 → 枚举的映射另有 AppSettings.toInterruptMode 与 InterruptModeTest 钉着。)
// (落盘字符串 → 枚举的映射另有顶层纯函数 toInterruptMode 与 InterruptModeTest 钉着。)
val fallback = AppPrefs().interruptMode
assertEquals(InterruptMode.AppendQueue, fallback)
@@ -94,7 +101,7 @@ class InterruptScopeTest {
}
@Test
fun `the two modes only differ while she is busy`() {
fun `the interrupt mode only differs from the default while she is busy`() {
// 空闲那一列两种档位必须**看起来一样**(都不发);差异只允许出现在「忙」这一列,
// 否则默认档就不是零行为变化了。
assertEquals(
@@ -106,4 +113,17 @@ class InterruptScopeTest {
interruptScopeForSend(InterruptMode.Interrupt, busy = true),
)
}
@Test
fun `the parallel mode is indistinguishable from the default on this axis however busy she is`() {
// 并行与打断互斥,所以这一档在**这条**轴上一个字节都不该多出来:忙闲两种情形都必须
// 与默认档完全一致(它的声明在另一条轴上,见 ParallelForSendTest)。
listOf(true, false).forEach { busy ->
assertEquals(
"busy=$busy 时并行档发出了打断帧——那会砍掉它自己要并行的那一路",
interruptScopeForSend(InterruptMode.AppendQueue, busy),
interruptScopeForSend(InterruptMode.Parallel, busy),
)
}
}
}
@@ -0,0 +1,185 @@
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.assertTrue
import org.junit.Test
import top.yeij.cyrene.core.prefs.AppPrefs
import top.yeij.cyrene.core.prefs.InterruptMode
import top.yeij.cyrene.data.ws.WsOutbound
/**
* 忙时并行的客户端半边:**发这条消息时要不要声明 `parallel:true`**(忙时并行规范 §2.2),
* 也就是 [interruptScopeForSend] 的兄弟 [parallelForSend]([InterruptScopeTest] 钉的是另一条轴)。
*
* 设置页那栏「她忙时发消息」现在是三选一,两条铁律在这里穷举钉死:
*
* | 档位 | 会话正忙时这一条怎么发 | `interrupt` | `chat.parallel` |
* |---|---|---|---|
* | 追加队列(默认) | 排队等(今天的行为) | **不发** | **不带**(线上逐字节不变) |
* | 打断 | 先取消在跑的那一轮,再发 | `scope:"current"` | **不带** |
* | 并行 | **不打断**,本条另起一路 | **绝不发** | `true` |
*
* 顺手把「空闲时三档逐字节完全相同」钉住:规范 §2.2 只写了 `parallel:true` 是「请求把本条作为
* 独立一路处理」,**没写**空闲时要不要照发。空闲时这条本来就是它自己的回合,声明与不声明在服务端
* 是同一件事 ⇒ 本端取与打断**同一个判据**(只在忙时声明),空闲时切档不会改变一个字节。
*/
class ParallelForSendTest {
/** 加入 `parallel` / 加入本档位之前那条 `chat` 帧的逐字形状。 */
private val legacyChat =
"""{"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"d","clientMsgId":"c-1"}}"""
/** 同上,但带上了 `parallel:true`(只有「并行」档在忙时才是这个形状)。 */
private val parallelChat =
"""{"type":"chat","data":{"sessionId":"web","text":"你好","deviceId":"d","clientMsgId":"c-1","parallel":true}}"""
/** 「打断」档在忙时那条帧。 */
private val interruptCurrent =
"""{"type":"interrupt","data":{"sessionId":"web","scope":"current"}}"""
/**
* 按偏好把**实际会铺到线上的帧序列**铺出来:`interrupt` 只在 scope 非 `null` 时才发,
* `chat` 永远发一条、`parallel` 键按判定带——与 `ChatViewModel.deliver` 接上
* `ChatSocket.sendChat` / `ChatRequest` 之后的行为同形(那边把这两步接在一起,这里把它摊平)。
*/
private fun wireOf(mode: InterruptMode, busy: Boolean): List<String> = buildList {
interruptScopeForSend(mode, busy)?.let {
add(WsOutbound.Interrupt(sessionId = "web", scope = it).toJson())
}
add(
WsOutbound.Chat(
text = "你好",
sessionId = "web",
deviceId = "d",
clientMsgId = "c-1",
parallel = parallelWireValue(parallelForSend(mode, busy)),
).toJson(),
)
}
// —— 判据本身 ——
@Test
fun `the default mode declares nothing even while she is busy`() {
// 最重要的一条:默认档是绝大多数用户手上的状态,它必须零行为变化。
assertFalse(parallelForSend(InterruptMode.AppendQueue, busy = true))
assertEquals(AppPrefs().interruptMode, InterruptMode.AppendQueue)
}
@Test
fun `the default mode declares nothing while she is idle either`() {
assertFalse(parallelForSend(InterruptMode.AppendQueue, busy = false))
}
@Test
fun `the parallel mode asks for its own leg while she is busy`() {
assertTrue(parallelForSend(InterruptMode.Parallel, busy = true))
}
@Test
fun `the parallel mode asks for nothing while she is idle`() {
// 空闲时这条本来就是它自己的回合:声明了服务端也没第二路可并。
assertFalse(parallelForSend(InterruptMode.Parallel, busy = false))
}
@Test
fun `the interrupt mode never declares a parallel leg`() {
// 「打断」与「并行」互斥:选了打断就是要她**先停**,同时再要一路并行是自相矛盾。
assertFalse(parallelForSend(InterruptMode.Interrupt, busy = true))
assertFalse(parallelForSend(InterruptMode.Interrupt, busy = false))
}
@Test
fun `the whole matrix of all three modes against both busy states is pinned`() {
// 3 档 × 2 忙闲 = 6 格,一次穷举钉死;将来任何人动这个判据,这里先红。
val matrix = InterruptMode.entries.flatMap { mode ->
listOf(true, false).map { busy -> "${mode.name}/busy=$busy" to parallelForSend(mode, busy) }
}
assertEquals(
listOf(
"AppendQueue/busy=true" to false,
"AppendQueue/busy=false" to false,
"Interrupt/busy=true" to false,
"Interrupt/busy=false" to false,
"Parallel/busy=true" to true,
"Parallel/busy=false" to false,
),
matrix,
)
}
@Test
fun `only the busy parallel cell ever declares a leg`() {
val declaring = InterruptMode.entries.filter { mode ->
listOf(true, false).any { busy -> parallelForSend(mode, busy) }
}
assertEquals(listOf(InterruptMode.Parallel), declaring)
}
// —— 两条铁律:并行绝不打断;另外两档绝不并行 ——
@Test
fun `the parallel mode never sends an interrupt frame however busy she is`() {
listOf(true, false).forEach { busy ->
assertNull("并行档发出打断帧就会砍掉它自己要并行的那一路", interruptScopeForSend(InterruptMode.Parallel, busy))
}
}
@Test
fun `no mode ever declares both an interrupt and a parallel leg`() {
InterruptMode.entries.forEach { mode ->
listOf(true, false).forEach { busy ->
assertFalse(
"${mode.name}/busy=$busy 同时要了打断与并行",
interruptScopeForSend(mode, busy) != null && parallelForSend(mode, busy),
)
}
}
}
// —— 铺到线上的形状 ——
@Test
fun `the whole wire shape of all three modes is pinned frame by frame`() {
assertEquals(listOf(legacyChat), wireOf(InterruptMode.AppendQueue, busy = true))
assertEquals(listOf(legacyChat), wireOf(InterruptMode.AppendQueue, busy = false))
assertEquals(listOf(interruptCurrent, legacyChat), wireOf(InterruptMode.Interrupt, busy = true))
assertEquals(listOf(legacyChat), wireOf(InterruptMode.Interrupt, busy = false))
// 并行:**没有** interrupt 帧,只有一条多带 `parallel:true` 的 chat 帧。
assertEquals(listOf(parallelChat), wireOf(InterruptMode.Parallel, busy = true))
assertEquals(listOf(legacyChat), wireOf(InterruptMode.Parallel, busy = false))
}
@Test
fun `while she is idle all three modes put exactly the same bytes on the wire`() {
// 空闲是绝大多数发送的情形;切档在这里必须**零差异**(三档的唯一差别是「她正忙时」)。
val wires = InterruptMode.entries.map { wireOf(it, busy = false) }
assertEquals(3, wires.size)
assertTrue("空闲时三档的帧不一致:$wires", wires.all { it == listOf(legacyChat) })
}
@Test
fun `the default mode keeps today's bytes on the wire while she is busy`() {
// 「一个字节都不变」的正面陈述:默认档忙时那条 chat 帧就是加入 `parallel` 之前的字面量。
assertEquals(listOf(legacyChat), wireOf(AppPrefs().interruptMode, busy = true))
}
// —— 判定 → 字段取值的那一步 ——
@Test
fun `a false judgement must omit the key rather than send an explicit false`() {
// `false` 与「省略」在服务端同义(都落回排队),但**线上形状不同**:带上 `"parallel":false`
// 就不再是「与加入该字段之前逐字节相同」。只有 true 才带上该键。
assertNull(parallelWireValue(false))
assertEquals(true, parallelWireValue(true))
assertEquals(legacyChat, wireOf(InterruptMode.AppendQueue, busy = true).last())
assertFalse(wireOf(InterruptMode.AppendQueue, busy = true).last().contains("parallel"))
}
}