W5: 新建会话时选角色(persona)

改了什么:
- `POST /api/v1/sessions` 支持可选 `personaId`:null(或全空白)走**不带 body** 的重载
  (与选角之前逐字节相同),非空才发 `{"personaId":"…"}`;新增 CreateSessionRequest /
  CreateSessionResponse.personaId / SessionSummaryDto.personaId。
- 新增**用户侧**角色目录取数:PersonaApi + UserPersonaDto + PersonaRepository(沿用 apiCall/Result)。
  刻意不用 admin 的 PersonaSummaryDto/AdminApi:用户侧不下发 admin 专属 thinking,admin 端点还要管理档。
- 抽屉「+」不再直接建会话,而是先开角色弹层(「默认角色」+ 各角色,角色名 + character 简述,
  失败/加载中各自有话说);确认后按所选创建。角色表拿不到时降级为「只有默认角色」,不挡建会话。
- 会话行摘要前缀该会话绑定的角色名:能拿到角色表就显示名字,拿不到就原样显示 id,未绑就不显示。

为什么:
- 此前 createSession 完全没有 body,新建的会话一律是默认角色,后端 v0.42 起的多角色能力用不上。
- 会话角色**创建后不可变**(`PATCH /sessions/{id}` 一旦收到 personaId 就 400),所以选角这一步
  只在建会话时问一次,也必须在建会话时问。

测试 353 → 385(+32),assembleDebug 与 testDebugUnitTest 均通过。
变异验证:把仓库改回「null 也发 body」后,SessionRepositoryCreateTest 有 2 条断言 FAIL。
This commit is contained in:
AskaEth committed 2026-10-04 18:06:11 +08:00
1 parent 664fd8dcef
commit 217cfe3d5f
15 files changed
+917 -11

No files matched your search

@@ -0,0 +1,162 @@
package top.yeij.cyrene.ui.sessions
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.api.dto.UserPersonaDto
/**
* 「新建会话时选角色」的三条纯逻辑:弹层选项构造、会话行上的角色名回填、摘要行拼接。
* 都不依赖 Compose,也不依赖服务端。
*/
class PersonaSelectionTest {
private fun persona(
id: String,
name: String = "",
character: String = "",
isDefault: Boolean = false,
) = UserPersonaDto(
id = id,
name = name,
character = character,
isDefault = isDefault,
)
// —— 弹层选项 ——
@Test
fun `the default role is always the first choice and binds nothing`() {
val choices = personaChoices(listOf(persona("xilian", name = "昔涟", character = "memokeeper")))
assertEquals(2, choices.size)
assertNull(choices[0].personaId)
assertEquals(DEFAULT_PERSONA_TITLE, choices[0].title)
assertEquals("xilian", choices[1].personaId)
assertEquals("昔涟", choices[1].title)
}
@Test
fun `an empty persona list still offers the default role`() {
// 角色目录没拉回来(或真的没有角色)时弹层不能是空的——那样连用默认角色建会话都做不到。
val choices = personaChoices(emptyList())
assertEquals(1, choices.size)
assertNull(choices.single().personaId)
assertEquals(DEFAULT_PERSONA_TITLE, choices.single().title)
}
@Test
fun `the server order is kept verbatim and no role is dropped`() {
val choices = personaChoices(listOf(persona("b", name = "第二个"), persona("a"), persona("z")))
assertEquals(listOf("b", "a", "z"), choices.drop(1).map { it.personaId })
}
@Test
fun `a blank name falls back to the id and a blank character means no summary`() {
val choices = personaChoices(listOf(persona("xilian", name = " ", character = " ")))
assertEquals("xilian", choices[1].title)
assertNull(choices[1].description)
}
@Test
fun `the character field becomes the summary verbatim`() {
// character 是开放集合:未知取值原样显示,客户端不做映射、也不丢。
val choices = personaChoices(listOf(persona("xilian", name = "昔涟", character = "memokeeper")))
assertEquals("memokeeper", choices[1].description)
}
@Test
fun `the default role names the server side default when there is one`() {
// 弹层里会出现两行「默认」:点名当前全局默认角色才不会让人选错。
val choices = personaChoices(listOf(persona("xilian", name = "昔涟", isDefault = true)))
assertEquals("不绑定角色,跟随全局默认(当前「昔涟」)", choices[0].description)
}
@Test
fun `the default role does not guess when nothing is marked default`() {
val choices = personaChoices(listOf(persona("xilian", name = "昔涟")))
assertEquals("不绑定角色,跟随全局默认", choices[0].description)
}
// —— 会话行上的角色名 ——
@Test
fun `a bound and known persona shows its name`() {
val choices = personaChoices(listOf(persona("xilian", name = "昔涟")))
assertEquals("昔涟", personaLabelFor("xilian", choices))
}
@Test
fun `a bound but unknown persona shows the raw id`() {
// 角色表没拉回来、或那个角色已经被删掉:原样显示 id,不猜、也不硬编码映射表。
assertEquals("ghost", personaLabelFor("ghost", personaChoices(emptyList())))
}
@Test
fun `an unbound session shows nothing at all`() {
val choices = personaChoices(listOf(persona("xilian", name = "昔涟")))
assertNull(personaLabelFor(null, choices))
assertNull(personaLabelFor("", choices))
assertNull(personaLabelFor(" ", choices))
}
// —— 摘要行 ——
@Test
fun `the persona label is prefixed to the preview`() {
assertEquals("昔涟 · 她说她一直在", sessionSummaryText("昔涟", "她说她一直在"))
}
@Test
fun `either half alone is enough for a summary line`() {
assertEquals("昔涟", sessionSummaryText("昔涟", null))
assertEquals("她说她一直在", sessionSummaryText(null, "她说她一直在"))
assertNull(sessionSummaryText(null, null))
assertNull(sessionSummaryText(" ", " "))
}
// —— 拉取结果 → 状态(只有成功才动列表) ——
@Test
fun `a successful load replaces the list and marks it loaded`() {
val state = SessionsUiState(personaError = "上一次失败").withPersonaResult(
loaded = listOf(persona("xilian", name = "昔涟")),
)
assertEquals(listOf(null, "xilian"), state.personas.map { it.personaId })
assertTrue(state.personasLoaded)
assertNull(state.personaError)
}
@Test
fun `a failed load keeps the previous list instead of wiping it`() {
val previous = SessionsUiState().withPersonaResult(loaded = listOf(persona("xilian", name = "昔涟")))
val failed = previous.withPersonaResult(failure = "连接失败")
assertEquals(previous.personas, failed.personas)
// 拿到过就是拿到过:一次刷新失败不该让弹层退回「加载中…」。
assertTrue(failed.personasLoaded)
assertEquals("连接失败", failed.personaError)
}
@Test
fun `a failure before the first success is an error, not a pretend empty list`() {
val failed = SessionsUiState().withPersonaResult(failure = "连接失败")
assertTrue(failed.personas.isEmpty())
// 这一条决定弹层显示「加载中…」还是失败原因——两者绝不能混在一起。
assertFalse(failed.personasLoaded)
assertEquals("连接失败", failed.personaError)
}
}
@@ -124,4 +124,12 @@ class SessionItemTest {
// 空列表是异常响应,不该拿它去改用户的选择。
assertEquals("web:alive", resolveActiveSession("web:alive", emptyList()))
}
@Test
fun `toItem keeps a bound persona and folds a blank one to null`() {
assertEquals("xilian", dto().copy(personaId = "xilian").toItem().personaId)
// 契约:空串与缺省同义(都是继承全局默认角色)——UI 上只该有「绑没绑」两种,空串不能漏到行上。
assertNull(dto().copy(personaId = "").toItem().personaId)
assertNull(dto().toItem().personaId)
}
}