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:
1 parent
664fd8dcef
commit
217cfe3d5f
15 files changed
+917
-11
No files matched your search
@@ -0,0 +1,98 @@
|
||||
package top.yeij.cyrene.data.api.dto
|
||||
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
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.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.ApiEnvelope
|
||||
|
||||
/**
|
||||
* **用户侧**角色目录(`GET /api/v1/personas`,api-client §1 行 9)的解码测试。
|
||||
*
|
||||
* 用户侧与 admin 侧同形,但**不下发 admin 专属的 `thinking`**——用的是独立的 [UserPersonaDto],
|
||||
* 这张测试同时钉住「多出 admin 专属键也不许崩」。手写载荷覆盖线上暂未出现、但契约允许的边界
|
||||
* (未知字段、整块缺字段、未知枚举取值)。
|
||||
*/
|
||||
class PersonaDtoDecodingTest {
|
||||
|
||||
private val json = ApiClientFactory.json
|
||||
|
||||
private val personaList = ListSerializer(UserPersonaDto.serializer())
|
||||
|
||||
private fun decode(body: String): List<UserPersonaDto> =
|
||||
json.decodeFromString(ApiEnvelope.serializer(personaList), body).data!!
|
||||
|
||||
@Test
|
||||
fun `a full user side row decodes field by field`() {
|
||||
val personas = decode(
|
||||
"""
|
||||
{"data":[{"id":"xilian","name":"昔涟","character":"memokeeper","isDefault":true,
|
||||
"mode":"chat","memoryNamespace":"xilian","avatar":"/a.png",
|
||||
"chatBackground":{"landscape":"/l.png","portrait":"/p.png"},"mood":"calm"}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val persona = personas.single()
|
||||
|
||||
assertEquals("xilian", persona.id)
|
||||
assertEquals("昔涟", persona.name)
|
||||
assertEquals("memokeeper", persona.character)
|
||||
assertTrue(persona.isDefault)
|
||||
assertEquals("chat", persona.mode)
|
||||
assertEquals("xilian", persona.memoryNamespace)
|
||||
assertEquals("/a.png", persona.avatar)
|
||||
assertEquals("/l.png", persona.chatBackground?.landscape)
|
||||
assertEquals("/p.png", persona.chatBackground?.portrait)
|
||||
assertEquals("calm", persona.mood)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a minimal row decodes with documented defaults instead of failing`() {
|
||||
val persona = decode("""{"data":[{"id":"xilian"}]}""").single()
|
||||
|
||||
assertEquals("xilian", persona.id)
|
||||
assertEquals("", persona.name)
|
||||
assertEquals("", persona.character)
|
||||
assertFalse(persona.isDefault)
|
||||
assertEquals("", persona.mode)
|
||||
assertEquals("", persona.memoryNamespace)
|
||||
assertNull(persona.avatar)
|
||||
assertNull(persona.chatBackground)
|
||||
assertNull(persona.mood)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank mood means no tracked mood and is not faked`() {
|
||||
// `""` = 该角色暂无被跟踪的情绪(契约明说),不是「后端没报告」,也不许伪造默认值。
|
||||
assertEquals("", decode("""{"data":[{"id":"xilian","mood":""}]}""").single().mood)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown mode and mood are kept raw instead of being dropped`() {
|
||||
val persona = decode("""{"data":[{"id":"xilian","mode":"telepathy","mood":"euphoric"}]}""").single()
|
||||
|
||||
assertEquals("telepathy", persona.mode)
|
||||
assertEquals("euphoric", persona.mood)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown keys are ignored and never break the decode`() {
|
||||
// 同一个后端结构还会长:admin 专属的 thinking、以及将来任何新键,用户侧遇到都不能炸。
|
||||
val persona = decode(
|
||||
"""
|
||||
{"data":[{"id":"xilian","name":"昔涟","thinking":{"budgetTokens":1,"effort":"low"},
|
||||
"nickname":"小昔","unknownList":[1,2]}]}
|
||||
""".trimIndent(),
|
||||
).single()
|
||||
|
||||
assertEquals("昔涟", persona.name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty persona list decodes to an empty list`() {
|
||||
assertTrue(decode("""{"data":[]}""").isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -229,4 +229,72 @@ class SessionDtoRealPayloadTest {
|
||||
assertEquals("web", data.sessionId)
|
||||
assertEquals(1_753_000_000_000L, data.readAt)
|
||||
}
|
||||
|
||||
// —— 新建会话时选角色(契约 v0.42) ——
|
||||
|
||||
@Test
|
||||
fun `a create request without a persona omits the key entirely`() {
|
||||
val body = json.encodeToString(CreateSessionRequest.serializer(), CreateSessionRequest())
|
||||
|
||||
// 服务端把「省略 personaId」与「personaId 为空」当同一件事(不绑,继承全局默认角色)。
|
||||
// 生产路径对 null 走的是**完全不带 body** 的重载(见 SessionRepositoryCreateTest);
|
||||
// 这里钉的是:真要序列化它,出来的也必须是 `{}`,绝不能是 `{"personaId":null}`。
|
||||
assertEquals("{}", body)
|
||||
assertFalse(body, body.contains("personaId"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a create request with a persona sends exactly one key`() {
|
||||
val body = json.encodeToString(
|
||||
CreateSessionRequest.serializer(),
|
||||
CreateSessionRequest(personaId = "xilian"),
|
||||
)
|
||||
|
||||
assertEquals("""{"personaId":"xilian"}""", body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a create response echoes the persona only when the session got bound`() {
|
||||
val bound = json.decodeFromString(
|
||||
ApiEnvelope.serializer(CreateSessionResponse.serializer()),
|
||||
"""{"data":{"sessionId":"web:abc","personaId":"xilian"}}""",
|
||||
).data!!
|
||||
|
||||
assertEquals("web:abc", bound.sessionId)
|
||||
assertEquals("xilian", bound.personaId)
|
||||
|
||||
// 不绑时响应形状与 v0.41 完全一致:`personaId` 整个键缺席。
|
||||
val unbound = json.decodeFromString(
|
||||
ApiEnvelope.serializer(CreateSessionResponse.serializer()),
|
||||
"""{"data":{"sessionId":"web:abc"}}""",
|
||||
).data!!
|
||||
|
||||
assertEquals("web:abc", unbound.sessionId)
|
||||
assertNull(unbound.personaId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a session row carries the optional persona id`() {
|
||||
val bound = json.decodeFromString(
|
||||
SessionSummaryDto.serializer(),
|
||||
"""{"id":"web:abc","type":"sub","createdAt":1,"pinned":false,"unreadCount":0,"personaId":"xilian"}""",
|
||||
)
|
||||
assertEquals("xilian", bound.personaId)
|
||||
|
||||
// 未绑的会话:键整个缺席(= 继承全局默认角色)。这是绝大多数行的形状。
|
||||
val unbound = json.decodeFromString(
|
||||
SessionSummaryDto.serializer(),
|
||||
"""{"id":"web","type":"main","createdAt":1}""",
|
||||
)
|
||||
assertNull(unbound.personaId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a legacy snapshot without the persona key still decodes`() {
|
||||
// 本版之前写下的那份快照里没有 personaId:升级后整份快照必须照样解得开,
|
||||
// 否则第一次启动会先闪一下空列表(同 unreadCount 的那条理由)。
|
||||
val legacy = """{"id":"web","type":"main","createdAt":1788724031196,"pinned":false,"unreadCount":0}"""
|
||||
|
||||
assertNull(json.decodeFromString(SessionSummaryDto.serializer(), legacy).personaId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package top.yeij.cyrene.data.repository
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange
|
||||
import com.sun.net.httpserver.HttpServer
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.core.error.CyreneError
|
||||
import top.yeij.cyrene.data.api.ApiClientFactory
|
||||
import top.yeij.cyrene.data.api.CyreneException
|
||||
import top.yeij.cyrene.data.api.SessionsApi
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
/**
|
||||
* `POST /sessions` 的**线级**行为:真 OkHttp + 真 Retrofit + JDK 自带 HTTP 服务端
|
||||
* (不引新依赖,与 [UploadRepositoryTest] / [ChatRepositoryTest] 同一手法)。
|
||||
*
|
||||
* 这里要钉住的是一件很容易改坏的事:**不选角色时发出去的请求必须与引入选角之前逐字节相同**。
|
||||
* 服务端 body 完全可选,「多一个 `personaId` 键」不是无害的——那会变成一次真的绑定尝试
|
||||
* (未知角色直接 `400`,而 `""` 还有它自己的语义)。所以 null 走的是**不带 body** 的重载,
|
||||
* 而不是 `{}`。
|
||||
*/
|
||||
class SessionRepositoryCreateTest {
|
||||
|
||||
private val backend = FakeSessionsBackend()
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
backend.close()
|
||||
}
|
||||
|
||||
private fun repository() = SessionRepository(
|
||||
ApiClientFactory.create(baseUrl = backend.baseUrl, service = SessionsApi::class.java),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `creating without a persona sends a body of zero bytes`() = runBlocking {
|
||||
backend.reply(201, """{"data":{"sessionId":"web:abc"}}""")
|
||||
|
||||
val created = repository().createSession().getOrThrow()
|
||||
|
||||
assertEquals("web:abc", created)
|
||||
val request = backend.requests.single()
|
||||
assertEquals("POST", request.method)
|
||||
assertEquals("/api/v1/sessions", request.path)
|
||||
// **一个字节都没有**:连 `{}` 都不是——契约说 body「可完全省略」,那就省略。
|
||||
assertEquals("", request.body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a blank persona id is treated as no persona at all`() = runBlocking {
|
||||
backend.reply(201, """{"data":{"sessionId":"web:abc"}}""")
|
||||
|
||||
repository().createSession(" ").getOrThrow()
|
||||
|
||||
// 「省略」与「空」在服务端是同一件事(不绑),所以没必要把 `{"personaId":""}` 发上去。
|
||||
assertEquals("", backend.requests.single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `creating with a persona sends exactly that one key`() = runBlocking {
|
||||
backend.reply(201, """{"data":{"sessionId":"web:abc","personaId":"xilian"}}""")
|
||||
|
||||
repository().createSession("xilian").getOrThrow()
|
||||
|
||||
assertEquals("""{"personaId":"xilian"}""", backend.requests.single().body)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown persona stays a server failure instead of silently falling back`() = runBlocking {
|
||||
backend.reply(400, """{"error":{"code":"bad_request","message":"unknown persona"}}""")
|
||||
|
||||
val error = repository().createSession("ghost").exceptionOrNull() as CyreneException
|
||||
|
||||
// 绝不「失败就改用默认角色重试」:那会建出一个用户没要的会话。
|
||||
assertEquals(CyreneError.Server("bad_request", "unknown persona", 400), error.error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 201 without data is a parse failure`() = runBlocking {
|
||||
backend.reply(201, "{}")
|
||||
|
||||
val error = repository().createSession().exceptionOrNull() as CyreneException
|
||||
|
||||
assertEquals(CyreneError.Local(CyreneError.LocalKind.Parse), error.error)
|
||||
}
|
||||
|
||||
/** 记录每次请求的原始字节,按 `reply(...)` 回话的假后端。 */
|
||||
private inner class FakeSessionsBackend : AutoCloseable {
|
||||
|
||||
private val server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0).apply {
|
||||
executor = Executors.newFixedThreadPool(2)
|
||||
createContext("/api/v1/sessions", ::handle)
|
||||
start()
|
||||
}
|
||||
|
||||
val baseUrl: String get() = "http://127.0.0.1:${server.address.port}/"
|
||||
|
||||
val requests = CopyOnWriteArrayList<RecordedRequest>()
|
||||
|
||||
@Volatile
|
||||
private var status: Int = 201
|
||||
|
||||
@Volatile
|
||||
private var body: String = ""
|
||||
|
||||
fun reply(status: Int, body: String) {
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
|
||||
private fun handle(exchange: HttpExchange) {
|
||||
// ISO-8859-1:字节 ↔ 字符一一对应,断言里比对的就是线上真实字节。
|
||||
val raw = exchange.requestBody.readBytes()
|
||||
requests += RecordedRequest(
|
||||
method = exchange.requestMethod,
|
||||
path = exchange.requestURI.path,
|
||||
body = raw.toString(Charsets.ISO_8859_1),
|
||||
)
|
||||
val bytes = body.toByteArray()
|
||||
exchange.responseHeaders.add("Content-Type", "application/json")
|
||||
exchange.sendResponseHeaders(status, bytes.size.toLong())
|
||||
exchange.responseBody.use { it.write(bytes) }
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { server.stop(0) }
|
||||
}
|
||||
}
|
||||
|
||||
private data class RecordedRequest(
|
||||
val method: String,
|
||||
val path: String,
|
||||
val body: String,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user