package memory import ( "context" "strings" "testing" ) // 本文件是「软动作必须真的从读侧消失」的回归(design/23 §8/§10)。 // // 整理(tidy)与入库前审查(D3)都只做软动作:改 status、写 merged_into/ // merged_from,**正文一个字不改、行一行不删**。因此只要有一条「能看到什么 // 记忆」的读路径忘了过 status 闸,清理就等于白清 —— 生产库上那 10 条 // merged 行就是现成的反例(它们当时照样参与召回、照样出现在面板里)。 // setItemStatusForTest 直接改一行的 status,模拟整理/审查落地后的软动作 // 结果。走 SQL 而不是走 apply 路径,是为了让本文件只验证**读侧**: // 写侧口径由 tidy_apply_*_test.go 与 preinsert_test.go 各自覆盖。 func setItemStatusForTest(t *testing.T, s *Store, id, status string) { t.Helper() if _, err := s.db.ExecContext(context.Background(), `UPDATE memory_items SET status = ? WHERE id = ?`, status, id); err != nil { t.Fatalf("set status=%s on %s: %v", status, id, err) } } // TestStore_读路径_过滤非在册行 逐条走四个列表读路径,证明 merged/archived // 行一条都不出现。四条第 2、4 条所在作用域(用户共享域、项目域)是跨角色 // 可读的,因此它们过去最容易漏闸。 func TestStore_读路径_过滤非在册行(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) activePersonaItem := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "在册-角色", Scope: ScopePersona, Kind: KindRelationship, }) mergedPersona := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "已合并-角色", Scope: ScopePersona, Kind: KindRelationship, }) activeUserItem := addItem(t, s, MemoryItem{ PersonaID: "persona.b", Content: "在册-用户", Scope: ScopeUser, Kind: KindWorldEvent, }) archivedUser := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "已归档-用户", Scope: ScopeUser, Kind: KindWorldEvent, }) activeProjectItem := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "在册-项目", Scope: ScopeProject, Kind: KindWorldEvent, ProjectID: "proj.1", }) mergedProject := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "已合并-项目", Scope: ScopeProject, Kind: KindWorldEvent, ProjectID: "proj.1", }) setItemStatusForTest(t, s, mergedPersona.ID, string(ingestStatusMerged)) setItemStatusForTest(t, s, archivedUser.ID, string(tidyStatusArchived)) setItemStatusForTest(t, s, mergedProject.ID, string(ingestStatusMerged)) for _, tc := range []struct { name string read func() ([]MemoryItem, error) want []string }{ { name: "ListByPersona", read: func() ([]MemoryItem, error) { return s.ListByPersona(ctx, "persona.a") }, // persona.a 自己的条目:角色域的在册行 + 它写的项目域在册行 // (项目域也属于本人身份的可见集)。 want: []string{"在册-项目", "在册-角色"}, }, { name: "List", read: func() ([]MemoryItem, error) { return s.List(ctx) }, want: []string{"在册-用户", "在册-项目", "在册-角色"}, }, { name: "ListScope/user", read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeUser, "persona.a") }, want: []string{"在册-用户"}, }, { name: "ListScope/project", read: func() ([]MemoryItem, error) { return s.ListScope(ctx, ScopeProject, "persona.a") }, want: []string{"在册-项目"}, }, { name: "ListProject", read: func() ([]MemoryItem, error) { return s.ListProject(ctx, "proj.1") }, want: []string{"在册-项目"}, }, } { t.Run(tc.name, func(t *testing.T) { got, err := tc.read() if err != nil { t.Fatalf("%s: %v", tc.name, err) } if len(got) != len(tc.want) { t.Fatalf("%s = %d items (%v), want %d (%v)", tc.name, len(got), contentsOf(got), len(tc.want), tc.want) } seen := make(map[string]bool, len(got)) for _, it := range got { seen[it.Content] = true if it.ID == mergedPersona.ID || it.ID == archivedUser.ID || it.ID == mergedProject.ID { t.Errorf("%s 返回了非在册行 %q(status 闸失效)", tc.name, it.Content) } } for _, want := range tc.want { if !seen[want] { t.Errorf("%s 缺少在册行 %q(got %v)", tc.name, want, contentsOf(got)) } } }) } // 反向护栏:Get 刻意不过 status 闸 —— 撤销软合并必须先读得到被吸收的行, // 否则 revert 无从下手(design/23 §8 的「无损可逆」)。 // 在册行这里一并断言,证明「不过滤」不是「读不到」。 for _, want := range []MemoryItem{ activePersonaItem, activeUserItem, activeProjectItem, mergedPersona, archivedUser, mergedProject, } { got, err := s.Get(ctx, want.ID) if err != nil { t.Errorf("Get(%s) err = %v, want readable", want.ID, err) continue } if got.Content != want.Content { t.Errorf("Get(%s).Content = %q, want %q", want.ID, got.Content, want.Content) } } } // TestRetriever_Retrieve_不召回已软合并行 是主人那条投诉的端到端判据: // 两条内容逐字相同的记忆,其中一条被软合并后,召回只能给出**一条**。 // 内容相同 ⇒ 相关度同分,闸一松两条都会进(这正是「重复记忆反复出现」 // 的现场机制)。 func TestRetriever_Retrieve_不召回已软合并行(t *testing.T) { t.Parallel() ctx := context.Background() s := newTestStore(t) survivor := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "主人喜欢喝冰美式", Scope: ScopePersona, Kind: KindRelationship, }) absorbed := addItem(t, s, MemoryItem{ PersonaID: "persona.a", Content: "主人喜欢喝冰美式", Scope: ScopePersona, Kind: KindRelationship, }) r := NewRetriever(s) before, err := r.Retrieve(ctx, "persona.a", "冰美式", 10) if err != nil { t.Fatalf("Retrieve before merge: %v", err) } if len(before) != 2 { t.Fatalf("合并前召回 = %d 条, want 2(前置条件:两条都进得去)", len(before)) } setItemStatusForTest(t, s, absorbed.ID, string(ingestStatusMerged)) after, err := r.Retrieve(ctx, "persona.a", "冰美式", 10) if err != nil { t.Fatalf("Retrieve after merge: %v", err) } if len(after) != 1 { t.Fatalf("合并后召回 = %d 条 (%v), want 1", len(after), idsOf(after)) } if after[0].Item.ID != survivor.ID { t.Errorf("合并后召回 = %s, want 存活的 %s", after[0].Item.ID, survivor.ID) } // Inject 是真正进上下文的那一步,也必须只剩一行。 injected, err := r.Inject(ctx, "persona.a", "冰美式", 10) if err != nil { t.Fatalf("Inject: %v", err) } if n := strings.Count(injected, "主人喜欢喝冰美式"); n != 1 { t.Errorf("Inject 文本里出现 %d 次重复记忆, want 1(%q)", n, injected) } // 归档路径同理:archived 也不该被召回。 setItemStatusForTest(t, s, survivor.ID, string(tidyStatusArchived)) gone, err := r.Retrieve(ctx, "persona.a", "冰美式", 10) if err != nil { t.Fatalf("Retrieve after archive: %v", err) } if len(gone) != 0 { t.Errorf("归档后召回 = %d 条 (%v), want 0", len(gone), idsOf(gone)) } } // contentsOf 取条目正文,仅用于失败信息。 func contentsOf(items []MemoryItem) []string { out := make([]string, 0, len(items)) for _, it := range items { out = append(out, it.Content) } return out } // idsOf 取条目 id,仅用于失败信息。 func idsOf(results []Result) []string { out := make([]string, 0, len(results)) for _, res := range results { out = append(out, res.Item.ID) } return out }