- 只翻注释/文档字符串:剥离注释后代码行逐字节不变(机械校验只剩 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)
168 lines
5.8 KiB
Go
168 lines
5.8 KiB
Go
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)
|
||
}
|
||
}
|