主人 2026-10-26 对「记忆分型」裁定「两列」:kind(关于什么)保留,新增 retention_tier(留多久 / 干什么用)。本片把这条轴从列、判定、写入、读侧过滤 一路打通,并把「判第五态 defer」与「判 retention_tier=temp」合并成一个判定。 结构(迁移 0026 + 自举前滚): - memory_items 新增 retention_tier TEXT NOT NULL DEFAULT ''(空串 = 尚未分型, 历史行与受限来源行),索引 idx_memory_items_retention_tier;up/down 均实跑过 (在隔离的生产库副本上 migrate → 建 Store → down → 再 up,5073 行零丢失)。 - NewStore 幂等前滚该列 + 建索引(ensureRetentionSchema),与迁移同形由测试钉住。 判定(retention.go,全仓唯一入口): - RetentionTier 闭集 temp/daily/work/long/knowledge + 空串未分型;与 kind 正交。 - resolveRetention 的优先级:调用方显式档位 > 显式有效期(⇒ temp)> 受限来源 不分型 > 留存规则 N1~N7 ⇒ temp > 默认 long。判据靠元数据,规则不许推翻显式档位。 - tempDeadline 是「档位 → expires_at」的唯一换算:temp ⇔ 有效期非空,两列同一笔 INSERT 落下(M2 登记过的「INSERT 与补写之间的短窗口」就此关闭)。 合并(defer ⇔ temp): - judgeIngest 不再跑留存规则,只读 Add 落定的 item.RetentionTier;规则调用点因此 全仓唯一(源码级断言钉住)。 - 判定不再依赖同步闸是否开启:闸未接/关着时档位与有效期照样落库。 - 异步层对已是 temp 的行不重复改写(避免 TTL 随审查延迟漂移)。 - IngestRuleVersion d3.3 → d3.4(判据作用范围变了,按既有硬规矩升版)。 读侧: - 四条列表读路径的 SELECT 与 scanItem 读出 retention_tier(14 列);Get 同样读回。 - 新增 ListByPersonaTiers / ListScopeTiers / ListTiers / ListProjectTiers(可选档位 过滤,缺省不过滤 ⇒ 历史行照常返回;显式传空串只取未分型;非法档位报错)。 原方法签名逐字不变 —— 网关 MemoryStore 接口钉着它们,可变参数会编译不过。 - store.go 的读路径拆到 store_list.go,守 500 行上限。 回归:go build ./... + go vet ./... + go test ./... 全绿(内存包另有 -race)。
448 lines
17 KiB
Go
448 lines
17 KiB
Go
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])
|
||
}
|
||
}
|