package memory import ( "context" "testing" "time" ) // 本文件用**固定夹具语料**(不是生产库)钉住「判 temp 的判定本身」与 // 「`defer` 与 `retention_tier=temp` 是同一个判定」这两条。 // // 语料分两半,方向是刻意相反的(任务书要求的「正例判得到 / 反例不会被判」): // // 正例 = design/03 §10.5 的 N1~N7 形态(寒暄、一次性指令、时效查询、指示性时点、 // 进行中状态)⇒ 必须判 temp; // 反例 = 长期事实 / 稳定偏好 + 约束 / 显式「记住」/ 习惯 ⇒ 必须**不**判 temp // (判错的后果不可逆:7 天后从所有读路径消失,随后被物理删除)。 // // 变异方向:把 resolveRetention 的 temp 分支删掉、或把规则改宽到「反例也命中」, // 这两组断言会分别变红 —— 它们不是「跑通就行」的烟雾测试。 // retentionFixture 是一条夹具:内容 + 期望落点 + 为什么。 type retentionFixture struct { content string want RetentionTier why string // rule 非空时一并断言命中的反例规则码(只有正例才需要,反例恒为 retentionNone)。 rule retentionRule } // retentionFixtures 是正反例语料。正例的每一条都能在 design/03 §10.5(N1~N7)找到 // 出处;反例的每一条都是「判成临时就会丢信息」的真实形态。 func retentionFixtures() []retentionFixture { return []retentionFixture{ // ── 正例(必须判 temp)──────────────────────────────────────────── {"谢谢啦", RetentionTemp, "N1 寒暄/致谢", retentionChatter}, {"早上好呀", RetentionTemp, "N1 寒暄", retentionChatter}, {"帮我订个 7 点的闹钟", RetentionTemp, "N3 一次性祈使(含数值也被祈使前缀先吃掉)", retentionNone}, {"现在几点了", RetentionTemp, "N4 时效性查询", retentionTimelyData}, {"用户昨天发来一卷验收纸条", RetentionTemp, "N6 指示性时点在句首", retentionTimepoint}, {"主人有一件正在慢慢考虑、还未决定的事", RetentionTemp, "N7 进行中状态(生产语料原样取回)", retentionInProgress}, // ── 反例(必须判 long)────────────────────────────────────────── {fixtureHabitShort, RetentionLong, "习惯陈述(熬夜)是长期事实", retentionNone}, {"记住:主人对花生过敏,点餐要避开", RetentionLong, "显式「记住」是指令,闸不许改判", retentionNone}, {"主人每天只喂小茂密一次猫粮,一次 2 勺", RetentionLong, "偏好 + 具体约束(数值/频次)", retentionNone}, {"主人被称作「叶酱」,不许用「喵喵七号」", RetentionLong, "称呼是硬性长期事实", retentionNone}, {"主人是上班族,工作日中午在食堂吃饭", RetentionLong, "职业/作息是长期事实", retentionNone}, } } // TestResolveRetentionFixtureCorpus 逐条钉住档位判定(正例判得到 / 反例不会被判)。 func TestResolveRetentionFixtureCorpus(t *testing.T) { t.Parallel() for _, tc := range retentionFixtures() { tc := tc t.Run(tc.content, func(t *testing.T) { t.Parallel() item := habitItem(tc.content, tidyTestTime(t), 0.9) verdict, err := resolveRetention(item) if err != nil { t.Fatalf("resolveRetention: %v", err) } if verdict.Tier != tc.want { t.Fatalf("%q 判成 %q, want %q(%s)", tc.content, verdict.Tier, tc.want, tc.why) } if tc.rule != retentionNone && verdict.Rule != tc.rule { t.Fatalf("%q 命中的规则 = %q, want %q(%s)", tc.content, verdict.Rule, tc.rule, tc.why) } }) } } // TestResolveRetentionWritePathMatchesFixture 把同一份语料压过**真实写入路径** // (Store.Add),断言落库的两列与判定一致 —— 判据与落点之间不许有第二套逻辑。 func TestResolveRetentionWritePathMatchesFixture(t *testing.T) { t.Parallel() store := newTestStore(t) for _, tc := range retentionFixtures() { tc := tc t.Run(tc.content, func(t *testing.T) { got := seedTidyItem(t, store, gateFixtureItem(tc.content, time.Time{})) tier, expires := readRetentionColumns(t, store, got.ID) if tier != tc.want { t.Fatalf("%q 落库档位 = %q, want %q", tc.content, tier, tc.want) } if tc.want == RetentionTemp && expires == "" { t.Fatalf("%q 判成 temp 却没写 expires_at(两列分叉)", tc.content) } if tc.want != RetentionTemp && expires != "" { t.Fatalf("%q 判成 %q 却写了 expires_at = %q", tc.content, tc.want, expires) } }) } } // TestDeferDecisionIsTheTempTier 钉住「`defer` 与 `retention_tier=temp` 是同一个判定」 // 在**闸开着**时成立:temp 档的条目拿到第五态 `defer` + `t0_low_value`,长期档的 // 条目走既有四态(create),且留痕的规则版本是 d3.4。 func TestDeferDecisionIsTheTempTier(t *testing.T) { t.Parallel() store := newTestStore(t) base := tidyTestTime(t) ttl := time.Hour r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil) store.AttachIngestReviewer(r) tempRow := seedTidyItem(t, store, gateFixtureItem("谢谢啦", base)) rev := gateRecordFor(t, store, tempRow.ID) if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason { t.Fatalf("temp 档留痕 = %+v, want defer + %q", rev, retentionLowValueReason) } if rev.RuleVer != IngestRuleVersion { t.Fatalf("留痕规则版本 = %q, want %q", rev.RuleVer, IngestRuleVersion) } tier, expires := readRetentionColumns(t, store, tempRow.ID) if tier != RetentionTemp { t.Fatalf("defer 行的档位 = %q, want %q", tier, RetentionTemp) } // 到期时刻来自**同一个判定**与**同一个时钟**(闸注入的 now + 面板 TTL)。 if want := expiryText(base.Add(ttl)); expires != want { t.Fatalf("defer 行 expires_at = %q, want %q(判定时刻 + TTL)", expires, want) } longRow := seedTidyItem(t, store, gateFixtureItem(fixtureHabitShort, base.Add(time.Minute))) if rev := gateRecordFor(t, store, longRow.ID); rev.Decision == IngestDefer { t.Fatalf("长期事实被判成 defer:%+v", rev) } } // TestTempTierDoesNotDependOnTheGate 钉住**档位判定不依赖同步闸**:闸没接(或关着) // 时,档位与有效期照样在同一笔 INSERT 落下。 // // M27 体检的缺口之一是「档位/时效只在闸的分支里换算」,于是一旦闸关闭, // `expires_at` 就再也没有生产者。本用例是那条缺口的回归钉子。 func TestTempTierDoesNotDependOnTheGate(t *testing.T) { t.Parallel() store := newTestStore(t) // 未接审查服务 ⇒ planIngest 走 passthrough got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", time.Time{})) tier, expires := readRetentionColumns(t, store, got.ID) if tier != RetentionTemp { t.Fatalf("闸未接时档位 = %q, want %q", tier, RetentionTemp) } if expires == "" { t.Fatal("闸未接时没写 expires_at —— 档位判定又退回到「只有闸才算」了") } // 这条行不会被投进任何队列、也不会有留痕:档位是写入侧的事实,不是审查的产物。 if got.RetentionTier != RetentionTemp { t.Fatalf("Add 返回的档位 = %q, want %q", got.RetentionTier, RetentionTemp) } } // TestAsyncReviewDoesNotMoveAnAlreadyTempRow 钉住异步审查层的**不漂移**: // 一条已经由 Add 判成 temp 的行被审查再判一次时,`expires_at` 不会被 // 「审查时刻 + TTL」重新写一遍(那会让同一条记忆的有效期随审查延迟漂移)。 func TestAsyncReviewDoesNotMoveAnAlreadyTempRow(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) r := newGateReviewer(t, store, ReviewConfig{TempTTL: time.Hour, DisableIngestGate: true}, nil) store.AttachIngestReviewer(r) got := seedTidyItem(t, store, gateFixtureItem("谢谢啦", base)) _, before := readRetentionColumns(t, store, got.ID) if before == "" { t.Fatal("Add 没写 expires_at(档位判定没生效)") } rev, err := r.Review(ctx, got) if err != nil { t.Fatalf("Review: %v", err) } if rev.Decision != IngestDefer { t.Fatalf("异步层结论 = %q, want %q(档位是 temp ⇒ defer)", rev.Decision, IngestDefer) } if _, after := readRetentionColumns(t, store, got.ID); after != before { t.Fatalf("异步审查把 expires_at 从 %q 改成了 %q —— 同一条记忆的有效期不该漂移", before, after) } }