package memory import ( "context" "os" "strings" "testing" "time" ) // 本文件是「留多久 / 干什么用」轴(迁移 0026、retention.go)的验收用例集。 // 它逐条钉住任务书的验收点: // // 1. 闭集:temp / daily / work / long / knowledge 五个档位 + 空串「尚未分型」; // 空串**不是**某一档,读侧能把两者分开(不过滤时原样返回,按空串过滤只取未分型); // 2. **正交性**:`kind`(关于什么)与 `retention_tier`(留多久)是两列两根轴, // relationship+temp / relationship+long / world_event+temp / world_event+knowledge // 四种组合都能存能读,互不推导; // 3. **不变量**:`tier=temp` ⇔ `expires_at` 非空且落在 TTL 窗口内;非 temp 恒为空串; // 4. **一个判定**:`defer` 的判据就是「档位是 temp」,且档位与有效期同一笔 INSERT // 落下(不依赖同步闸是否开启)。 // readRetentionColumns 直读一行的档位与有效期。档位在 Store.Get 的读侧面上,有效期 // **刻意只写不读**(store.go 的 ExpiresAt 注释),因此要验证落库形态只能直读。 func readRetentionColumns(t *testing.T, store *Store, id string) (RetentionTier, string) { t.Helper() var tier, expires string if err := store.db.QueryRowContext(context.Background(), `SELECT retention_tier, expires_at FROM memory_items WHERE id = ?`, id). Scan(&tier, &expires); err != nil { t.Fatalf("read retention columns %s: %v", id, err) } return RetentionTier(tier), expires } // seedHistoricalItem 造一条**0026 之前**的历史行:档位停在列默认值(空串 = 未分型)。 // // 它只能直写 SQL 造出来 —— `Store.Add` 总会判出一个档位,这正是「未分型只属于 // 历史行与受限来源」这条口径的结构性证据(不是在测试里塞一个假值)。 func seedHistoricalItem(t *testing.T, store *Store, personaID, content string) string { t.Helper() ctx := context.Background() id, err := newID() if err != nil { t.Fatalf("newID: %v", err) } now := formatTime(time.Now().UTC()) if _, err := store.db.ExecContext(ctx, `INSERT INTO memory_items (id, persona_id, scope, kind, content, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, id, personaID, string(ScopePersona), string(KindRelationship), content, now, now); err != nil { t.Fatalf("seed historical item: %v", err) } return id } // TestRetentionTierClosedSet 钉住闭集本身:五个业务档位 + 未分型都是合法列值, // 而 `assigned()` 只对非空串为真(这是「未分型」的唯一判据)。 func TestRetentionTierClosedSet(t *testing.T) { t.Parallel() cases := []struct { tier RetentionTier valid bool assigned bool }{ {RetentionTierUnset, true, false}, {RetentionTemp, true, true}, {RetentionDaily, true, true}, {RetentionWork, true, true}, {RetentionLong, true, true}, {RetentionKnowledge, true, true}, {"ephemeral", false, true}, {"TEMP", false, true}, {"long ", false, true}, } for _, tc := range cases { if got := tc.tier.valid(); got != tc.valid { t.Fatalf("RetentionTier(%q).valid() = %t, want %t", tc.tier, got, tc.valid) } if got := tc.tier.assigned(); got != tc.assigned { t.Fatalf("RetentionTier(%q).assigned() = %t, want %t", tc.tier, got, tc.assigned) } } // 闭集里的五个业务档位一个都不能少(少了哪一档,「两列」的语义就不完整)。 for _, want := range []RetentionTier{RetentionTemp, RetentionDaily, RetentionWork, RetentionLong, RetentionKnowledge} { if !want.valid() || !want.assigned() { t.Fatalf("档位 %q 不在闭集里", want) } } } // TestAddRejectsUnknownRetentionTier 钉住「闭集是硬约束」:非法档位在 Add 入口被拒, // 不会带着脏值落库(否则读侧的过滤会把 `TEMP` 当成另一种 temp)。 func TestAddRejectsUnknownRetentionTier(t *testing.T) { t.Parallel() store := newTestStore(t) item := baseItem() item.RetentionTier = RetentionTier("ephemeral") if _, err := store.Add(context.Background(), item); err == nil { t.Fatal("非法档位竟被接受 —— 闭集没有生效") } } // TestRetentionTierIsOrthogonalToKind 钉住任务书的正交性验收点:`kind`(关于什么) // 与 `retention_tier`(留多久)是两根轴,四种组合都能存能读,且读回来逐字不变。 // // 变异方向:若有人写出「relationship ⇒ long」「world_event ⇒ temp」这类推导, // 这四条里立刻有两条读回来的档位与写入时不同。 func TestRetentionTierIsOrthogonalToKind(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) cases := []struct { name string kind Kind tier RetentionTier content string }{ {"关系+临时", KindRelationship, RetentionTemp, "谢谢啦"}, {"关系+长期", KindRelationship, RetentionLong, "主人习惯被称作「叶酱」"}, {"世界事件+临时", KindWorldEvent, RetentionTemp, "早上好呀"}, {"世界事件+知识库", KindWorldEvent, RetentionKnowledge, "迁移号约定:接着上一个号递增"}, } for _, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { item := baseItem() item.Kind = tc.kind item.RetentionTier = tc.tier item.Content = tc.content got, err := store.Add(ctx, item) if err != nil { t.Fatalf("Add: %v", err) } if got.RetentionTier != tc.tier { t.Fatalf("Add 返回的档位 = %q, want %q", got.RetentionTier, tc.tier) } loaded, err := store.Get(ctx, got.ID) if err != nil { t.Fatalf("Get: %v", err) } if loaded.Kind != tc.kind || loaded.RetentionTier != tc.tier { t.Fatalf("读回 (kind=%q, tier=%q), want (%q, %q) —— 两列是两根独立的轴", loaded.Kind, loaded.RetentionTier, tc.kind, tc.tier) } }) } } // TestTempTierImpliesExpiryWithinTTLWindow 钉住不变量的一半:**被判成 temp 的行, // 有效期一定落在 TTL 窗口内**,且由同一笔 INSERT 写下(没有第二笔 UPDATE 的窗口)。 // // 这里没有接审查服务 ⇒ 走「墙钟 + 代码默认 tempTtlSec」那一档换算;接了审查服务的 // 情况由 TestDeferDecisionIsTheTempTier 用固定时钟钉。 func TestTempTierImpliesExpiryWithinTTLWindow(t *testing.T) { t.Parallel() store := newTestStore(t) ttl := time.Duration(tempTtlSec) * time.Second before := time.Now().UTC() got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", time.Time{})) after := time.Now().UTC() tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionTemp { t.Fatalf("短寒暄的档位 = %q, want %q(留存规则没接上)", tier, RetentionTemp) } at, err := parseTime(expires) if err != nil { t.Fatalf("parse expires_at %q: %v", expires, err) } if !at.After(before) || at.After(after.Add(ttl)) { t.Fatalf("expires_at = %v 不在 TTL 窗口 (%v, %v] 内", at, before, after.Add(ttl)) } } // TestNonTempTierHasNoExpiry 钉住不变量的另一半:**非 temp 档的行 expires_at 恒为空串** // (因此四条列表读路径恒可见、后台清扫结构性跳过它们)。 func TestNonTempTierHasNoExpiry(t *testing.T) { t.Parallel() store := newTestStore(t) got := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, time.Time{})) tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionLong { t.Fatalf("长期事实的档位 = %q, want %q(默认落点)", tier, RetentionLong) } if expires != "" { t.Fatalf("非 temp 档的 expires_at = %q, want 空串", expires) } } // TestExplicitTierIsNotOverriddenByRules 钉住 AGENTS §9 的取向:**调用方的显式档位 // 是元数据,词项规则不许推翻它**。一条「短寒暄」按规则会被判 temp,但调用方明确说 // 它是知识库条目 ⇒ 它是 knowledge 且没有有效期。 func TestExplicitTierIsNotOverriddenByRules(t *testing.T) { t.Parallel() store := newTestStore(t) item := gateFixtureItem("谢谢啦", time.Time{}) item.RetentionTier = RetentionKnowledge got := seedTidyItem(t, store, item) tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionKnowledge { t.Fatalf("显式档位被规则推翻:tier = %q, want %q", tier, RetentionKnowledge) } if expires != "" { t.Fatalf("knowledge 档却写下了 expires_at = %q", expires) } } // TestRetentionInputContradictions 钉住两个自相矛盾的输入都不许静默通过: // // ① 非 temp 档 + 非零有效期 ⇒ 报错(一行标着长期却会到期消失); // ② 未分型 + 非零有效期 ⇒ 判 temp(「会到期」就是临时记忆,这是同一个事实的补全)。 func TestRetentionInputContradictions(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) bad := gateFixtureItem(fixtureHabitShort, time.Time{}) bad.RetentionTier = RetentionLong bad.ExpiresAt = time.Now().UTC().Add(time.Hour) if _, err := store.Add(ctx, bad); err == nil { t.Fatal("「长期档 + 有效期」竟被接受 —— 两列会当场分叉") } implied := gateFixtureItem(fixtureHabitShort, time.Time{}) implied.ExpiresAt = time.Now().UTC().Add(time.Hour) got, err := store.Add(ctx, implied) if err != nil { t.Fatalf("Add(只给有效期): %v", err) } if tier, _ := readRetentionColumns(t, store, got.ID); tier != RetentionTemp { t.Fatalf("只给有效期时档位 = %q, want %q(有到期时刻就是临时记忆)", tier, RetentionTemp) } } // TestRestrictedSourceStaysUntyped 钉住受限来源(群聊 / 陌生人)**不分型**: // 判它 temp 会造出一条「读面隐藏、后台清扫又跳过(expire.go 的 nonOwnerSource)」 // 的行 —— 那是一条永远不会被删的幽灵行。 func TestRestrictedSourceStaysUntyped(t *testing.T) { t.Parallel() store := newTestStore(t) item := gateFixtureItem("谢谢啦", time.Time{}) item.SourceConversation = FormatSourceConversation("sess-channel-42", "msg-9") got := seedTidyItem(t, store, item) tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionTierUnset { t.Fatalf("受限来源的档位 = %q, want 未分型(空串)", tier) } if expires != "" { t.Fatalf("受限来源被写了 expires_at = %q(会变成永不删除的隐藏行)", expires) } } // tierFixtureSet 是档位过滤用例的四条夹具行(四条覆盖四种档位状态): // long(长期事实,persona 作用域)、temp(临时,user 作用域)、knowledge // (知识库,project 作用域)、以及一条**未分型的历史行**(只能直写 SQL 造)。 type tierFixtureSet struct { longID, tempID, projectID, historicalID string } // seedTierFixtureSet 造出四条夹具行。 func seedTierFixtureSet(t *testing.T, store *Store) tierFixtureSet { t.Helper() set := tierFixtureSet{} set.longID = seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, time.Time{})).ID tempItem := gateFixtureItem("谢谢啦", time.Time{}) tempItem.Scope = ScopeUser set.tempID = seedTidyItem(t, store, tempItem).ID projectItem := gateFixtureItem("迁移号约定:接着上一个号递增", time.Time{}) projectItem.Scope = ScopeProject projectItem.ProjectID = "proj-1" projectItem.RetentionTier = RetentionKnowledge set.projectID = seedTidyItem(t, store, projectItem).ID set.historicalID = seedHistoricalItem(t, store, "persona.a", "一条 0026 之前就存在的记忆") return set } // tierIDs 把条目列表折成 id 集合(断言用)。 func tierIDs(items []MemoryItem) map[string]bool { out := make(map[string]bool, len(items)) for _, it := range items { out[it.ID] = true } return out } // TestListTiersFilter 钉住读侧的**可选**过滤(跨角色的 List / ListTiers): // // - 不传档位 = 不过滤:**历史行(空串)照常返回**(既有调用方零改动); // - 传档位 = 只返回这些档位(可多档并集); // - 显式传空串 = 只返回**未分型**的行(回填分型的入口,必须与「不过滤」可区分)。 func TestListTiersFilter(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) set := seedTierFixtureSet(t, store) // 不过滤:四类行都在(含未分型的历史行)。 all, err := store.List(ctx) if err != nil { t.Fatalf("List: %v", err) } got := tierIDs(all) for _, id := range []string{set.longID, set.tempID, set.projectID, set.historicalID} { if !got[id] { t.Fatalf("不过滤时漏掉了 %s(缺省必须不过滤,历史行照常返回)", id) } } // 只看 temp:只剩那条临时记忆。 temps, err := store.ListTiers(ctx, RetentionTemp) if err != nil { t.Fatalf("ListTiers(temp): %v", err) } if got := tierIDs(temps); len(got) != 1 || !got[set.tempID] { t.Fatalf("ListTiers(temp) = %v, want 只有 %s", got, set.tempID) } // 只看未分型:只剩那条历史行(与「不过滤」必须不同)。 untyped, err := store.ListTiers(ctx, RetentionTierUnset) if err != nil { t.Fatalf("ListTiers(unset): %v", err) } if got := tierIDs(untyped); len(got) != 1 || !got[set.historicalID] { t.Fatalf("ListTiers('') = %v, want 只有 %s(空串必须能与不过滤区分)", got, set.historicalID) } // 多档位并集。 two, err := store.ListTiers(ctx, RetentionTemp, RetentionKnowledge) if err != nil { t.Fatalf("ListTiers(temp, knowledge): %v", err) } if got := tierIDs(two); len(got) != 2 || !got[set.tempID] || !got[set.projectID] { t.Fatalf("ListTiers(temp, knowledge) = %v, want {%s, %s}", got, set.tempID, set.projectID) } } // TestListTiersFilterByScope 钉住同一口径在三条**带分区**的读路径上成立 // (ListByPersonaTiers / ListScopeTiers / ListProjectTiers),并钉住非法档位 // **必须报错**而不是静默退化成「不过滤」。 func TestListTiersFilterByScope(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) set := seedTierFixtureSet(t, store) personaLong, err := store.ListByPersonaTiers(ctx, "persona.a", RetentionLong) if err != nil { t.Fatalf("ListByPersonaTiers: %v", err) } if got := tierIDs(personaLong); len(got) != 1 || !got[set.longID] { t.Fatalf("ListByPersonaTiers(long) = %v, want 只有 %s", got, set.longID) } // 历史行也是 persona.a 的,但档位是空串 ⇒ 在 long 过滤里不该出现。 personaUntyped, err := store.ListByPersonaTiers(ctx, "persona.a", RetentionTierUnset) if err != nil { t.Fatalf("ListByPersonaTiers(unset): %v", err) } if got := tierIDs(personaUntyped); len(got) != 1 || !got[set.historicalID] { t.Fatalf("ListByPersonaTiers('') = %v, want 只有 %s", got, set.historicalID) } userTemp, err := store.ListScopeTiers(ctx, ScopeUser, "persona.a", RetentionTemp) if err != nil { t.Fatalf("ListScopeTiers: %v", err) } if got := tierIDs(userTemp); len(got) != 1 || !got[set.tempID] { t.Fatalf("ListScopeTiers(user, temp) = %v, want 只有 %s", got, set.tempID) } projectKnown, err := store.ListProjectTiers(ctx, "proj-1", RetentionKnowledge) if err != nil { t.Fatalf("ListProjectTiers: %v", err) } if got := tierIDs(projectKnown); len(got) != 1 || !got[set.projectID] { t.Fatalf("ListProjectTiers(knowledge) = %v, want 只有 %s", got, set.projectID) } // 非法档位:报错,不许静默退化成「不过滤」。 calls := []struct { name string run func() error }{ {"ListByPersonaTiers", func() error { _, err := store.ListByPersonaTiers(ctx, "persona.a", "ephemeral") return err }}, {"ListTiers", func() error { _, err := store.ListTiers(ctx, "ephemeral"); return err }}, {"ListScopeTiers", func() error { _, err := store.ListScopeTiers(ctx, ScopeUser, "persona.a", "ephemeral") return err }}, {"ListProjectTiers", func() error { _, err := store.ListProjectTiers(ctx, "proj-1", "ephemeral") return err }}, } for _, call := range calls { if err := call.run(); err == nil { t.Fatalf("%s 接受了非法档位(应当报错,而不是当「不过滤」)", call.name) } } } // TestRetentionJudgementHasSingleRuleCallSite 是**防回归的源码级断言**:留存规则 // (`judgeExtractionRetention`)在生产代码里只能有一个调用点 —— `resolveRetention`。 // // 它钉的是本切片的核心承诺「**不许两套规则各判一遍**」:M27 体检的缺口(1082 次审查 // 只判出 1 条 defer、`expires_at` 全表为空)根因就是档位与 defer 各判一次、谁都不完整。 // 有人把规则调用再塞回 `judgeIngest` / 闸 / 异步审查时,本用例会红。 func TestRetentionJudgementHasSingleRuleCallSite(t *testing.T) { t.Parallel() files, err := os.ReadDir(".") if err != nil { t.Fatalf("read package dir: %v", err) } var sites []string for _, f := range files { name := f.Name() if f.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { continue } src, err := os.ReadFile(name) if err != nil { t.Fatalf("read %s: %v", name, err) } for _, line := range strings.Split(string(src), "\n") { trimmed := strings.TrimSpace(line) if !strings.Contains(trimmed, "judgeExtractionRetention(") { continue } if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "func ") { continue } sites = append(sites, name+": "+trimmed) } } if len(sites) != 1 { t.Fatalf("judgeExtractionRetention 的调用点 = %d 处,want 恰为 1(resolveRetention):\n%s", len(sites), strings.Join(sites, "\n")) } if !strings.HasPrefix(sites[0], "retention.go:") { t.Fatalf("唯一调用点应在 retention.go 的 resolveRetention 里,实得 %s", sites[0]) } }