Files
AskaEth 09fba77f87
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/history(15 文件,纯替换)
- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 4 条行尾注释的翻译,代码前缀全等)
- 交叉引用(design/16 §4.6、D4/B3、0010 迁移文件名)逐字保留
- 保留未译技术词:DDL/rowid/LIKE/LEFT JOIN/DTO/rune/WS/actor 及全部标识符与表名
- 顺带副作用:store.go、session_persona_test.go 由注释文字改变而变得 gofmt 干净(未运行 gofmt -w)
2026-10-04 12:28:48 +08:00

168 lines
5.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package history
import (
"context"
"errors"
"testing"
)
// personaOf 返回某个会话已存储的角色绑定,会话不在列表中时使测试失败。
func personaOf(t *testing.T, st *Store, id string) string {
t.Helper()
sessions, err := st.ListSessions(context.Background())
if err != nil {
t.Fatalf("list sessions: %v", err)
}
for _, m := range sessions {
if m.ID == id {
return m.PersonaID
}
}
t.Fatalf("session %q not found in %+v", id, sessions)
return ""
}
// TestSessionPersonaRoundTrip 是 design/22 验收 1:会话级角色是唯一事实来源,
// 因此通过 SetSessionPersona 写入的值必须能从直接读取与列表投影(客户端拿到的
// DTO)两条路径读回,而始终存在的主会话初始为未绑定(空 = 继承全局默认)。
// 显式空串会把绑定清回同一状态。
func TestSessionPersonaRoundTrip(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
if got := personaOf(t, st, MainSessionID); got != "" {
t.Fatalf("main session persona_id = %q, want '' (inherit the global default)", got)
}
if got, err := st.SessionPersona(ctx, MainSessionID); err != nil || got != "" {
t.Fatalf("SessionPersona(main) = (%q, %v), want ('', nil)", got, err)
}
for _, personaID := range []string{"cyrene", "yuki"} {
if err := st.SetSessionPersona(ctx, MainSessionID, personaID); err != nil {
t.Fatalf("SetSessionPersona(%s): %v", personaID, err)
}
if got, err := st.SessionPersona(ctx, MainSessionID); err != nil || got != personaID {
t.Fatalf("SessionPersona(main) = (%q, %v), want (%q, nil)", got, err, personaID)
}
if got := personaOf(t, st, MainSessionID); got != personaID {
t.Fatalf("ListSessions persona_id = %q, want %q", got, personaID)
}
}
// 显式空值是文档化的「解绑」(重新跟随默认),不是空操作。
if err := st.SetSessionPersona(ctx, MainSessionID, ""); err != nil {
t.Fatalf("SetSessionPersona(\"\"): %v", err)
}
if got := personaOf(t, st, MainSessionID); got != "" {
t.Fatalf("after unbind persona_id = %q, want ''", got)
}
}
// TestSessionPersonaUnknownSession 覆盖错误契约:绑定或读取一个不存在的会话的
// 角色会报 ErrSessionNotFound(因此 HTTP 层回答 404),而不是悄悄创建/查不到
// 任何东西。
func TestSessionPersonaUnknownSession(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
if err := st.SetSessionPersona(ctx, "web:missing", "cyrene"); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("SetSessionPersona(missing) err = %v, want ErrSessionNotFound", err)
}
if _, err := st.SessionPersona(ctx, "web:missing"); !errors.Is(err, ErrSessionNotFound) {
t.Fatalf("SessionPersona(missing) err = %v, want ErrSessionNotFound", err)
}
}
// TestNewSubSessionForBindsPersona 锁定创建路径:带角色创建的子会话从第一次读取
// 起就携带它(无需额外 PATCH),而朴素的 NewSubSession 仍创建未绑定会话 —— 即
// 多角色之前的契约,每个既有调用方都依赖它。
func TestNewSubSessionForBindsPersona(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
bound, err := st.NewSubSessionFor(ctx, "owner", "yuki")
if err != nil {
t.Fatalf("NewSubSessionFor: %v", err)
}
if got := personaOf(t, st, bound); got != "yuki" {
t.Fatalf("bound sub persona_id = %q, want yuki", got)
}
plain, err := st.NewSubSession(ctx, "owner")
if err != nil {
t.Fatalf("NewSubSession: %v", err)
}
if got := personaOf(t, st, plain); got != "" {
t.Fatalf("plain sub persona_id = %q, want ''", got)
}
}
// TestMessagePersonaPersisted 证明 design/22 §7-5 的存储一半:一个会话可以在保留
// 上下文的同时切换角色,因此每条消息都记录说出它的那个角色。该列通过 REST/WS
// DTO 使用的同一个扫描器活过写入/读取往返,而未指定角色写入的消息保持为空
// (= 默认角色),这正是升级前历史仍可读的原因。
func TestMessagePersonaPersisted(t *testing.T) {
t.Parallel()
st := newStore(t)
ctx := context.Background()
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "yuki 你好", PersonaID: "yuki"}); err != nil {
t.Fatalf("append with persona: %v", err)
}
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "在~"}); err != nil {
t.Fatalf("append without persona: %v", err)
}
msgs, err := st.List(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(msgs) != 2 {
t.Fatalf("listed %d messages, want 2", len(msgs))
}
byRole := map[string]string{}
for _, m := range msgs {
byRole[m.Role] = m.PersonaID
}
if got := byRole[RoleUser]; got != "yuki" {
t.Fatalf("user message persona_id = %q, want yuki", got)
}
if got := byRole[RoleAssistant]; got != "" {
t.Fatalf("unattributed message persona_id = %q, want ''", got)
}
// persona 列还必须活过 Recent(WS 回放路径)与 AppendFull(编排器的实时
// 同步路径)。
recent, err := st.Recent(ctx, MainSessionID, 10)
if err != nil {
t.Fatalf("recent: %v", err)
}
found := false
for _, m := range recent {
if m.PersonaID == "yuki" {
found = true
}
}
if !found {
t.Fatalf("Recent lost the message persona: %+v", recent)
}
full, err := st.AppendFull(ctx, Message{SessionID: MainSessionID, Role: RoleAssistant, Content: "记住了", PersonaID: "yuki"})
if err != nil {
t.Fatalf("append full: %v", err)
}
if full.PersonaID != "yuki" {
t.Fatalf("AppendFull returned persona_id = %q, want yuki", full.PersonaID)
}
after, err := st.ListAfter(ctx, MainSessionID, 0, 10, "")
if err != nil {
t.Fatalf("list after: %v", err)
}
if len(after) == 0 || after[len(after)-1].PersonaID != "yuki" {
t.Fatalf("ListAfter lost the message persona: %+v", after)
}
}