主人原话:「收窄提取器,确实要给他一个主动式的记忆留存规则,不能想到什么都 往记忆里写」「.env 只做兜底用 …这些键优先从面板配置上取」「临时记忆到期删除」。 按 design/23 §18(D3 第五态 defer)、§19(到期处置)、design/03 §9.6 (tempTtlSec 默认 7 天 + 坏值口径)、§10(提取留存规则)落地。 - IngestDecision 闭集加第五态 defer:照常 active 落库 + 写短 expires_at 落 临时层(不删行、不改正文,回滚 = 清空该列);IngestRuleVersion d3.2 → d3.3。 - 落点:ingestGatePlan 增 ExpiresAt + expiresAt()(走 expiryText/formatTime 的 定宽 30 字节 UTC,与读侧时效闸同一套文本口径),由 finishIngest 在 defer 一态 补写 expires_at(INSERT 与补写之间的短暂窗口如实登记,方向 = 宁可多留)。 非 defer 三态一笔都不多写。 - T0 提取留存规则闸(extract_retention.go):design/03 §10.5 的 N1~N5 纯规则, 接进 judgeIngest 的候选比对之前,命中即 defer + reason=t0_low_value,不进 T1; 两道护栏(显式「记住」/ 持久信号)一票放行,fail-safe 方向 = 宁可多留。 - T1 闭集扩为 merge/modify/keep/defer(同一个 prompt、同一次调用,绝不新增 第二次模型调用):只有关系判为真新时才问价值;defer 要求 target=0,否则判 非法回落 create(reason=t1_reply_invalid)。 - 提取器提示词加固(design/03 §10.3 B):正向清单 + 反例清单 + 「允许返回 []」。 - 临时记忆 TTL:代码默认 tempTtlSec = 604800(7 天);面板 $DATA_DIR/config/memory-tidy.json 的 tempTtlSec 优先(ApplyReviewTuning 逐键 解析,坏值回落默认 + WARN,绝不写进 cfg)。该键在 .env 里没有出处且不新增 (.env 已冻结)⇒ 实际生效链是「面板 > 代码默认」。启动播种同步该键(*int + omitempty,nil 省略、绝不写 null),保证播种文件与生效值逐字一致。 - preinsert_store.go 的 listReviewCandidatesSQL 原位注释:这条内部召回 SQL 刻意不过时效闸的裁定与理由(design/03 §11.2 第 3 条)。 - 测试:defer 落库带短 expires_at(闸 + T1 两条路径)、写入口径与时效闸同源、 TTL 坏值与回落、规则版本 d3.3、整理候选幂等键不含 IngestRuleVersion、 提取规则正例 11 条 / 反例 11 条。 验收:go build ./... + go vet ./... + go test ./... 全绿;windows/darwin 交叉 编译通过(GOOS=darwin GOARCH=arm64 / GOOS=windows GOARCH=amd64)。
388 lines
16 KiB
Go
388 lines
16 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.3`(改判定规则必升版,否则新旧留痕无法区分);
|
||
// 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.3`。
|
||
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.3" {
|
||
t.Fatalf("闸留痕规则版本 = %q, want d3.3", 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.setIngestExpiry(ctx, got.ID, expiryText(time.Now().UTC().Add(-time.Minute))); err != nil {
|
||
t.Fatalf("setIngestExpiry: %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.setIngestExpiry(ctx, got.ID, ""); err != nil {
|
||
t.Fatalf("setIngestExpiry(清空): %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("两种布局的日期前缀应当相同,否则说明取的时间不是同一瞬间")
|
||
}
|
||
}
|
||
|
||
// TestIngestRuleVersionIsD33 钉住任务书验收点④:改判定规则必升版
|
||
// (design/23 §18.4),否则新旧留痕无法区分。
|
||
func TestIngestRuleVersionIsD33(t *testing.T) {
|
||
t.Parallel()
|
||
if IngestRuleVersion != "d3.3" {
|
||
t.Fatalf("IngestRuleVersion = %q, want d3.3(第五态改变了判定闭集)", 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 之后,**既有 pending 整理
|
||
// 候选会不会凭幂等键复制出重复行**?
|
||
//
|
||
// 答案:**不会**,因为这是两个独立的常量 ——
|
||
//
|
||
// IngestRuleVersion ("d3.3") 入库审查(本切片)→ 只进 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 赋值 —— 幂等键的口径变了")
|
||
}
|
||
}
|