主人 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)。
392 lines
17 KiB
Go
392 lines
17 KiB
Go
package memory
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"log/slog"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// 本文件是**第五态 `defer`**(design/23 §18)与**临时 TTL 配置面**
|
||
// (design/03 §9.6)的验收用例集。它逐条覆盖任务书的验收点:
|
||
//
|
||
// 1. defer 落库带**短** `expires_at`(闸路径 + 异步 T1 路径);
|
||
// 2. 写进去的 `expires_at` 与读侧时效闸**同一套规范文本**
|
||
// (定宽 30 字节 UTC —— 写错布局会让闸静默判错,本仓有历史事故);
|
||
// 3. TTL 坏值(≤0)**绝不放行**:回落代码默认 7 天 + WARN;
|
||
// 4. `IngestRuleVersion` 升到 `d3.4`(改判定规则必升版,否则新旧留痕无法区分);
|
||
// 5. defer 是**软动作**:回滚 = 清空 `expires_at`,行与正文一字不动。
|
||
|
||
// readIngestExpiry 直读一行记忆的 `expires_at`(该列不在 Store.Get 的读侧面上:
|
||
// 读侧只拿它做 SQL 判据,不回表 SELECT)。
|
||
func readIngestExpiry(t *testing.T, store *Store, id string) string {
|
||
t.Helper()
|
||
var exp string
|
||
if err := store.db.QueryRowContext(context.Background(),
|
||
`SELECT expires_at FROM memory_items WHERE id = ?`, id).Scan(&exp); err != nil {
|
||
t.Fatalf("read expires_at %s: %v", id, err)
|
||
}
|
||
return exp
|
||
}
|
||
|
||
// personaListHas 报告一条记忆是否出现在 ListByPersona(**过时效闸**)的结果里。
|
||
func personaListHas(t *testing.T, store *Store, personaID, id string) bool {
|
||
t.Helper()
|
||
items, err := store.ListByPersona(context.Background(), personaID)
|
||
if err != nil {
|
||
t.Fatalf("ListByPersona: %v", err)
|
||
}
|
||
for _, it := range items {
|
||
if it.ID == id {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// newRealClockReviewer 构建一个用**真实墙钟**的审查服务(闸开着)。读侧时效闸比
|
||
// 的是真实当下,因此「这条临时记忆现在还看得见吗」这类断言必须用真实时钟,否则
|
||
// 固定夹具时刻(2026-10-01)算出来的有效期早就过去了。
|
||
func newRealClockReviewer(t *testing.T, store *Store, ttl time.Duration) *PreInsertReviewer {
|
||
t.Helper()
|
||
r, err := NewPreInsertReviewer(ReviewConfig{
|
||
Enabled: true, QueueSize: 8, ScanLimit: 50, Timeout: 5 * time.Second,
|
||
Threshold: 0.80, TempTTL: ttl,
|
||
}, ReviewDeps{
|
||
Store: store,
|
||
Now: func() time.Time { return time.Now() },
|
||
Log: testLogger(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
return r
|
||
}
|
||
|
||
// deferFixtureItem 是一条**留存规则闸必然判 defer** 的条目(短寒暄,同分区、
|
||
// owner 来源)。
|
||
func deferFixtureItem(content string, at time.Time) MemoryItem {
|
||
return gateFixtureItem(content, at)
|
||
}
|
||
|
||
// ttlPanelPtr 造「面板里显式写了 tempTtlSec」的指针(nil 表示键没写)。
|
||
func ttlPanelPtr(v int) *int { return &v }
|
||
|
||
// TestIngestGateDeferWritesShortExpiresAt 是任务书验收点①的**闸路径**:
|
||
// 低价值条目照常 active 落库(一个字不丢),`expires_at` = 判定时刻 + 临时 TTL,
|
||
// 留痕是 `defer` / `t0_low_value` / 规则版本 `d3.4`。
|
||
func TestIngestGateDeferWritesShortExpiresAt(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
ttl := time.Hour
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: ttl}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
|
||
|
||
want := expiryText(base.Add(ttl))
|
||
if exp := readIngestExpiry(t, store, got.ID); exp != want {
|
||
t.Fatalf("expires_at = %q, want %q(= 判定时刻 + 临时 TTL)", exp, want)
|
||
}
|
||
if exp := readIngestExpiry(t, store, got.ID); len(exp) != storedTimeLen {
|
||
t.Fatalf("expires_at 宽度 = %d 字节, want %d(读侧是纯文本比较,宽度必须同源)",
|
||
len(exp), storedTimeLen)
|
||
}
|
||
row := readReviewRow(t, store, got.ID)
|
||
if row.status != tidyStatusActive || row.content != "谢谢啦" {
|
||
t.Fatalf("defer 行 = %+v, want 仍在册且正文一字不动(软动作)", row)
|
||
}
|
||
|
||
rev := gateRecordFor(t, store, got.ID)
|
||
if rev.Decision != IngestDefer || rev.Reason != retentionLowValueReason {
|
||
t.Fatalf("闸留痕 = %+v, want decision=defer reason=%q", rev, retentionLowValueReason)
|
||
}
|
||
if rev.RuleVer != "d3.4" {
|
||
t.Fatalf("闸留痕规则版本 = %q, want d3.4", rev.RuleVer)
|
||
}
|
||
if s := r.Stats(); s.GateDeferred != 1 {
|
||
t.Fatalf("闸计数 = %+v, want gate_deferred=1", s)
|
||
}
|
||
}
|
||
|
||
// TestIngestGateDeferIsNotEnqueuedForAsyncReview 钉住「defer 已是终局判断」:
|
||
// 它**不投**异步审查队列 —— 再送语义审查只会把这条临时记忆并进一条长期记忆、
|
||
// 把临时性抹掉(design/23 §18.1 的落点)。
|
||
func TestIngestGateDeferIsNotEnqueuedForAsyncReview(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
base := tidyTestTime(t)
|
||
r := newGateReviewer(t, store, ReviewConfig{TempTTL: time.Hour}, nil)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
seedTidyItem(t, store, deferFixtureItem("谢谢啦", base))
|
||
if n := len(r.queue); n != 0 {
|
||
t.Fatalf("defer 条目被投进了异步队列(队列长度 %d)—— defer 是终局判断,不该再送审", n)
|
||
}
|
||
|
||
// 对偶:正常条目仍照旧投队列(证明上面那条不是因为队列压根不工作)。
|
||
seedTidyItem(t, store, deferFixtureItem(fixtureHabitShort, base.Add(time.Minute)))
|
||
if n := len(r.queue); n != 1 {
|
||
t.Fatalf("正常条目未被投进异步队列(队列长度 %d, want 1)", n)
|
||
}
|
||
}
|
||
|
||
// TestT1DeferWritesExpiresAtOnKeptNewRow 是验收点①的**异步 T1 路径**:T1 在
|
||
// **同一次调用**里回 `defer` ⇒ 新行保留、正文不动、既有行不动,只写短 TTL。
|
||
func TestT1DeferWritesExpiresAtOnKeptNewRow(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
existing, fresh := seedT1GrayPair(t, store)
|
||
ttl := 2 * time.Hour
|
||
|
||
judge := &t1JudgeStub{reply: `{"action":"defer","target":0,"reason":"只是一次性的信息"}`}
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: ttl}, judge.judge, nil)
|
||
|
||
rev, err := r.Review(ctx, fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestDefer || rev.Reason != t1ReasonDefer {
|
||
t.Fatalf("verdict = %+v, want defer + %q", rev, t1ReasonDefer)
|
||
}
|
||
if judge.count() != 1 {
|
||
t.Fatalf("模型调用 = %d 次, want 恰为 1(第五态与关系判定同一次调用)", judge.count())
|
||
}
|
||
want := expiryText(tidyTestTime(t).Add(ttl))
|
||
if exp := readIngestExpiry(t, store, fresh.ID); exp != want {
|
||
t.Fatalf("expires_at = %q, want %q", exp, want)
|
||
}
|
||
row := readReviewRow(t, store, fresh.ID)
|
||
if row.status != tidyStatusActive || row.content != fixtureT1CodeViaChannel {
|
||
t.Fatalf("新行 = %+v, want 仍在册且承载自己的原文", row)
|
||
}
|
||
if got := readReviewRow(t, store, existing.ID); got.mergeCount != 1 {
|
||
t.Fatalf("既有行被改了:%+v(defer 只该碰新行的 expires_at 一列)", got)
|
||
}
|
||
}
|
||
|
||
// TestT1DeferWithTargetIsReplyInvalid 钉住 §18.2 的不变式「defer ≡ 真新 ∧ 短期」:
|
||
// 一个**指向候选**的 defer 自相矛盾(既说和它像、又说这条独立)⇒ 判非法回复、
|
||
// 回落 `create`(**宁可多留**),绝不猜。
|
||
func TestT1DeferWithTargetIsReplyInvalid(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
_, fresh := seedT1GrayPair(t, store)
|
||
|
||
judge := &t1JudgeStub{reply: `{"action":"defer","target":1,"reason":"自相矛盾"}`}
|
||
r := newT1Reviewer(t, store, t1GrayEmbedder(), ReviewConfig{TempTTL: time.Hour}, judge.judge, nil)
|
||
|
||
rev, err := r.Review(ctx, fresh)
|
||
if err != nil {
|
||
t.Fatalf("Review: %v", err)
|
||
}
|
||
if rev.Decision != IngestCreate || rev.Reason != t1ReasonReplyInvalid {
|
||
t.Fatalf("verdict = %+v, want create + %q", rev, t1ReasonReplyInvalid)
|
||
}
|
||
if exp := readIngestExpiry(t, store, fresh.ID); exp != "" {
|
||
t.Fatalf("非法 defer 却写了 expires_at = %q(回落路径必须零改写)", exp)
|
||
}
|
||
}
|
||
|
||
// TestDeferExpiryHiddenByReadGateAndRollbackRestores 把第五态接到**读侧时效闸**
|
||
// (design/03 §9.6 第 ① 步隐藏)与**回滚**(§18.4:清空 expires_at 即升回长期)上:
|
||
// 未到期可见 ⇒ 置成过去即隐藏 ⇒ 清空又可见,且 `Get`(刻意不过闸)始终读得到。
|
||
func TestDeferExpiryHiddenByReadGateAndRollbackRestores(t *testing.T) {
|
||
t.Parallel()
|
||
ctx := context.Background()
|
||
store := newTestStore(t)
|
||
r := newRealClockReviewer(t, store, time.Hour)
|
||
store.AttachIngestReviewer(r)
|
||
|
||
got := seedTidyItem(t, store, deferFixtureItem("谢谢啦", time.Now().UTC()))
|
||
if !personaListHas(t, store, got.PersonaID, got.ID) {
|
||
t.Fatal("刚落的临时记忆应当在有效期内可见(TTL = 1h)")
|
||
}
|
||
|
||
if err := store.setIngestRetention(ctx, got.ID, RetentionTemp, expiryText(time.Now().UTC().Add(-time.Minute))); err != nil {
|
||
t.Fatalf("setIngestRetention: %v", err)
|
||
}
|
||
if personaListHas(t, store, got.PersonaID, got.ID) {
|
||
t.Fatal("已到期的临时记忆不该出现在读路径上(时效闸失效)")
|
||
}
|
||
if _, err := store.Get(ctx, got.ID); err != nil {
|
||
t.Fatalf("Get 刻意不过时效闸(撤销/清算要读得到过期行),却报错:%v", err)
|
||
}
|
||
|
||
// 回滚(design/23 §18.4):清空 expires_at ⇒ 升回长期,立即可见。
|
||
if err := store.setIngestRetention(ctx, got.ID, RetentionLong, ""); err != nil {
|
||
t.Fatalf("setIngestRetention(回滚): %v", err)
|
||
}
|
||
if !personaListHas(t, store, got.PersonaID, got.ID) {
|
||
t.Fatal("清空 expires_at 后应当升回长期(回滚是无损的)")
|
||
}
|
||
if row := readReviewRow(t, store, got.ID); row.content != "谢谢啦" || row.status != tidyStatusActive {
|
||
t.Fatalf("回滚改了正文/状态:%+v", row)
|
||
}
|
||
}
|
||
|
||
// TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate 是写入/读取**同一套规范文本**
|
||
// 的钉子(塔在派工里点名要求补的那条)。
|
||
//
|
||
// 读侧闸是**纯文本比较**(timegate.go 的 timeGateClause,不碰 SQLite 时间函数),
|
||
// 所以写侧的布局一旦分叉,闸会**静默判错**:本仓历史事故里,`' '`(0x20) 分隔的
|
||
// `datetime('now')` 与 `'T'`(0x54) 分隔的列值比较,会让**任何当日行都「大于当下」**。
|
||
func TestIngestGatePlanExpiresAtUsesSameLayoutAsTimeGate(t *testing.T) {
|
||
t.Parallel()
|
||
at := tidyTestTime(t).UTC()
|
||
|
||
// 写入侧:defer 计划给出的文本必须就是 expiryText/formatTime 的产出。
|
||
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); got != formatTime(at) {
|
||
t.Fatalf("plan.expiresAt() = %q, want formatTime 的产出 %q", got, formatTime(at))
|
||
}
|
||
if got := (ingestGatePlan{ExpiresAt: at}).expiresAt(); len(got) != storedTimeLen {
|
||
t.Fatalf("plan.expiresAt() 宽度 = %d, want %d", len(got), storedTimeLen)
|
||
}
|
||
// 零值 = 不设过期(长期/日常记忆)⇒ 空串,恒可见。
|
||
if got := (ingestGatePlan{}).expiresAt(); got != "" {
|
||
t.Fatalf("零值计划的 expiresAt() = %q, want 空串", got)
|
||
}
|
||
|
||
// 反证:同一瞬间换一种布局,文本序就与时间序脱钩(读侧判据随之失效)。
|
||
sqliteLayout := at.Format("2006-01-02 15:04:05")
|
||
if formatTime(at) == sqliteLayout {
|
||
t.Fatal("两种布局不该相同 —— 那这条反证就没有意义了")
|
||
}
|
||
if !(formatTime(at) > sqliteLayout) {
|
||
t.Fatalf("定宽布局应恒大于空格分隔布局('T' > ' '),实得 %q vs %q", formatTime(at), sqliteLayout)
|
||
}
|
||
if sqliteLayout[0:4] != formatTime(at)[0:4] {
|
||
t.Fatal("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间")
|
||
}
|
||
}
|
||
|
||
// TestIngestRuleVersionIsD34 钉住任务书验收点④:改判定规则必升版
|
||
// (design/23 §18.4),否则新旧留痕无法区分。
|
||
//
|
||
// d3.4(切片 M37)把「判 defer」与「判 retention_tier=temp」合并成同一个判定,
|
||
// 规则内容未改但判据的作用范围变了(每一行都在同一笔 INSERT 里落档位与有效期,
|
||
// 且不再依赖同步闸是否开启),因此按规矩升版。
|
||
func TestIngestRuleVersionIsD34(t *testing.T) {
|
||
t.Parallel()
|
||
if IngestRuleVersion != "d3.4" {
|
||
t.Fatalf("IngestRuleVersion = %q, want d3.4(档位与 defer 合并为同一判定)", IngestRuleVersion)
|
||
}
|
||
}
|
||
|
||
// TestApplyReviewTuningTempTTL 钉住验收点③:TTL 的三档优先级与**坏值绝不放行**。
|
||
//
|
||
// 三档优先级在 TTL 这一键上实际是「**面板 > 代码默认**」:`.env` 已冻结
|
||
// (design/13 §2.1),本键在那里**从来就没有出处** —— 中间那一档在结构上是空的,
|
||
// 这不是漏配(design/03 §9.6 写的就是「面板 > 代码默认」)。
|
||
func TestApplyReviewTuningTempTTL(t *testing.T) {
|
||
t.Parallel()
|
||
const fallbackSec = 604800 // 7 天(tempTtlSec)
|
||
|
||
cases := []struct {
|
||
name string
|
||
panel *int
|
||
wantTTL time.Duration
|
||
wantWarn bool
|
||
}{
|
||
{"键没写 ⇒ 代码默认", nil, 7 * 24 * time.Hour, false},
|
||
{"面板写正数 ⇒ 用面板值", ttlPanelPtr(3600), time.Hour, false},
|
||
{"面板写 0 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(0), 7 * 24 * time.Hour, true},
|
||
{"面板写负数 ⇒ 坏值回落默认 + WARN", ttlPanelPtr(-3600), 7 * 24 * time.Hour, true},
|
||
}
|
||
for _, tc := range cases {
|
||
tc := tc
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
var buf bytes.Buffer
|
||
log := slog.New(slog.NewTextHandler(&buf, nil))
|
||
got := normalizeReviewConfig(ApplyReviewTuning(
|
||
ReviewConfig{}, runtimeconfig.MemoryTidyTuning{TempTtlSec: tc.panel}, log))
|
||
if got.TempTTL != tc.wantTTL {
|
||
t.Fatalf("TempTTL = %v, want %v", got.TempTTL, tc.wantTTL)
|
||
}
|
||
hasWarn := strings.Contains(buf.String(), "临时记忆 TTL 不可用")
|
||
if hasWarn != tc.wantWarn {
|
||
t.Fatalf("WARN 出现 = %t, want %t(日志:%s)", hasWarn, tc.wantWarn, buf.String())
|
||
}
|
||
if got.TempTTL <= 0 {
|
||
t.Fatal("归一化后仍是非正 TTL —— 那会让每条 defer 记忆落库即过期")
|
||
}
|
||
})
|
||
}
|
||
|
||
// 代码默认值本身就是 design/03 §9.6 点名的 604800 秒。
|
||
if normalizeReviewConfig(ReviewConfig{}).TempTTL != time.Duration(fallbackSec)*time.Second {
|
||
t.Fatalf("代码默认 TTL = %v, want %ds", normalizeReviewConfig(ReviewConfig{}).TempTTL, fallbackSec)
|
||
}
|
||
if tempTtlSec != fallbackSec {
|
||
t.Fatalf("tempTtlSec = %d, want %d", tempTtlSec, fallbackSec)
|
||
}
|
||
}
|
||
|
||
// TestNewPreInsertReviewerNormalizesBadTempTTL 钉住「坏值不靠调用方自觉」:
|
||
// 任何绕过解析器、直接把 ≤0 塞进 ReviewConfig 的路径,也在构造期被归一化掉
|
||
// (与 T1 限流两项同一教训,design/12 §N C.5 G3)。
|
||
func TestNewPreInsertReviewerNormalizesBadTempTTL(t *testing.T) {
|
||
t.Parallel()
|
||
store := newTestStore(t)
|
||
for _, ttl := range []time.Duration{0, -time.Hour} {
|
||
r, err := NewPreInsertReviewer(ReviewConfig{Enabled: true, TempTTL: ttl}, ReviewDeps{
|
||
Store: store, Log: testLogger(),
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||
}
|
||
if got := r.cfg.TempTTL; got != 7*24*time.Hour {
|
||
t.Fatalf("ReviewConfig.TempTTL = %v 未回落默认,实得 %v", ttl, got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestIngestRuleVersionIsNotInTheTidyIdempotencyKey 是**防漂移缝断言**,回答一条
|
||
// 跨机制的实体问题:`IngestRuleVersion` 由 d3.2 升到 d3.3(现为 d3.4)之后,**既有 pending 整理
|
||
// 候选会不会凭幂等键复制出重复行**?
|
||
//
|
||
// 答案:**不会**,因为这是两个独立的常量 ——
|
||
//
|
||
// IngestRuleVersion ("d3.4") 入库审查(本切片)→ 只进 memory_ingest_reviews.rule_version
|
||
// TidyRuleVersion ("t0.1") 整理(design/23 §17.6)→ **进整理候选的幂等键**
|
||
//
|
||
// 幂等键的算式是 `tidyIdempotencyKey`(tidy_candidate.go):hash(persona_id, scope,
|
||
// project_id, sorted(source_ids), action, **c.RuleVersion**),而候选的 RuleVersion 由
|
||
// 构造器写成 TidyRuleVersion。生产库实测(2026-10-13,只读):`memory_tidy_candidates`
|
||
// 的 64 条 pending 全部 `rule_version='t0.1'`,`memory_ingest_reviews` 里则是
|
||
// `d3.1` / `d3.2` —— 两张表**从不共享**这一个值。
|
||
//
|
||
// design/23 §17.6 记录过的那次「升版 ⇒ 既有候选凭新键复制一份重复行」是
|
||
// **TidyRuleVersion** 的语义(那也是它当时刻意不升的原因),不是这一个。
|
||
// 若将来有人把 IngestRuleVersion 接进那个键(或把两个常量合并),本用例会红。
|
||
func TestIngestRuleVersionIsNotInTheTidyIdempotencyKey(t *testing.T) {
|
||
t.Parallel()
|
||
if IngestRuleVersion == TidyRuleVersion {
|
||
t.Fatalf("两个版本号不该相等(都是 %q):它们分属入库审查与整理两条机制",
|
||
IngestRuleVersion)
|
||
}
|
||
src, err := os.ReadFile("tidy_candidate.go")
|
||
if err != nil {
|
||
t.Fatalf("读 tidy_candidate.go: %v", err)
|
||
}
|
||
text := string(src)
|
||
if !strings.Contains(text, "string(c.Action), c.RuleVersion") {
|
||
t.Fatal("整理候选幂等键的算式变了 —— 请复核 IngestRuleVersion 是否意外进了键")
|
||
}
|
||
if strings.Contains(text, "IngestRuleVersion") {
|
||
t.Fatal("tidy_candidate.go 里出现了 IngestRuleVersion:两个版本号开始互相污染")
|
||
}
|
||
if strings.Count(text, "TidyRuleVersion") < 2 {
|
||
t.Fatal("整理候选的 RuleVersion 不再由 TidyRuleVersion 赋值 —— 幂等键的口径变了")
|
||
}
|
||
}
|