Files
Cyrene_v2/internal/runtime/memory/tidy_apply_test.go
T
AskaEth 6d92536276
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(memory): 整理不再只产建议 —— 轮末自动落地高置信 + 停产 link 候选
主人原话:「那么多 link 是干什么的?该删就删,怎么这个记忆整理机制这么保守呢」。

① **link 停产**(依据 design/23 §14 把它列为「未细化」⇒ 既无落点也无数据模型;
   `applyable()` 也只认 merge/archive/reclassify ⇒ 应用 link 恒 `skipped`,是纯噪音):
   - 规则层 `containNone` 判定改为 `keep`(不再产出 501 条不可应用的待办);
   - `TidyLink` 常量保留,只为**读历史行**与「不可应用」判定;
   - 存量出路:新端点 `POST /api/v1/admin/memories/candidates/reject?action=&limit=`
     (**幂等、只改候选 status、不碰记忆**),主会话用它清掉存量 501 条。
② **轮末自动落地**(默认开,仅两类,判据是纯函数):
   - `merge`:**字面重复** ∨「事实集合等价 ∧ 相似度 ≥ 0.95」;
   - `archive`:理由 = `one_off_event` **且不像习惯**(`design/23 §5.2-1` 红线:
     习惯类会因正文带钟点被误判成一次性事件,归档它是错的)。
   - **不自动**:`reclassify`、低置信 merge、跨分区/跨角色、任何改正文、受限来源
     (群聊/陌生人)—— 新增执行面守卫 `tidySkipSourceRestricted`。
③ **六条护栏**(缺一不可):软动作(复用既有 apply 路径)/ 每轮上限 50(硬顶 500,
   超限如实 `Truncated` + 真总数)/ 审计 `memory.tidy.apply`(`auto=true`,不带正文)/
   **可 revert**(复用既有回滚)/ 幂等(复用候选状态条件更新)/ **一键关回只产建议**
   (`$DATA_DIR/config/memory-tidy.json` 的 `autoApply:false`,**零新 env 键**)。

验收(主会话复跑 + 亲自变异):memory 与 gateway 两包 build/vet/test 全绿;我用
-overlay 短路「习惯不归档」守卫 ⇒ TestTidyAutoNeverArchivesHabitMisjudgedAsOneOff
变红(逐字报「作息习惯被自动归档了 …design §5.2-1 红线」)。

⚠️ 未部署(要 build+restart 才生效);⚠️ 契约欠登记(新端点 B25 + §7.2),主会话补;
⚠️ 真机端到端未跑(不能重启后端)。
2026-10-07 11:07:00 +08:00

495 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package memory
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
)
// 本文件锁定记忆整理**应用路径**(切片 TIDY-1;design/23 §7/§8)的六条判据:
//
// ① merge = 软合并:被吸收行 status='merged' + merged_into,存活行
// merged_from/merge_count 更新,**content 逐字不变**,memory_items 总行数不变;
// ② **幂等**:重复应用同一条候选 ⇒ skipped/already_decided,且不产生第二次副作用
// (merge_count 不再涨、没有第二行被改);
// ③ **可 revert**:回滚后字段复原(status/merged_into/merged_from/merge_count/kind),
// 行数不变,候选退回 pending;
// ④ archive = 软删(status='archived'),reclassify = 改 kind,都不碰 content;
// ⑤ link 不可应用 ⇒ skipped/action_not_applyable,零改动;
// ⑥ 批量(按 run)有上限、逐条结果、超限如实报 Truncated + Total。
// tidyItemRow 是断言用的一行记忆关键字段(含正文,用来钉「绝不改 content」)。
type tidyItemRow struct {
status string
mergedInto string
mergedFrom string
mergeCount int
content string
kind string
}
// loadTidyItemRow 读一行记忆的整理列 + 正文。
func loadTidyItemRow(t *testing.T, store *Store, id string) tidyItemRow {
t.Helper()
var r tidyItemRow
err := store.db.QueryRowContext(context.Background(),
`SELECT status, merged_into, merged_from, merge_count, content, kind
FROM memory_items WHERE id = ?`, id).
Scan(&r.status, &r.mergedInto, &r.mergedFrom, &r.mergeCount, &r.content, &r.kind)
if err != nil {
t.Fatalf("读记忆行 %s: %v", id, err)
}
return r
}
// countMemoryItems 数 memory_items 的总行数(软删除 ⇒ 应用前后必须不变)。
func countMemoryItems(t *testing.T, store *Store) int {
t.Helper()
var n int
if err := store.db.QueryRowContext(context.Background(),
`SELECT COUNT(*) FROM memory_items`).Scan(&n); err != nil {
t.Fatalf("数 memory_items: %v", err)
}
return n
}
// mergeCandidateFromRun 跑一轮整理并返回唯一的 merge 候选(顺带把运行 id 带出来)。
func mergeCandidateFromRun(t *testing.T, store *Store, emb embedding.Embedder) TidyCandidate {
t.Helper()
tr := newTestTidier(t, store, tidyTestCfg(0.80), emb)
stats, err := tr.RunOnce(context.Background())
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands, err := store.ListTidyCandidates(context.Background(), "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
merges := candidatesByAction(cands, TidyMerge)
if len(merges) != 1 {
t.Fatalf("merge 候选 = %d, want 1(本轮 run=%s)", len(merges), stats.RunID)
}
if merges[0].RunID != stats.RunID {
t.Fatalf("候选 run_id = %q, want 本轮 run_id = %q", merges[0].RunID, stats.RunID)
}
return merges[0]
}
// TestTidyApplyMergeIsSoftIdempotentAndRevertible 是①②③三条判据的合并用例:
// 软合并 → 重复应用无副作用 → 回滚后字段复原。
func TestTidyApplyMergeIsSoftIdempotentAndRevertible(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
cand := mergeCandidateFromRun(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
if cand.SourceIDs[0] != short.ID || cand.TargetID != long.ID {
t.Fatalf("候选方向不对:sources=%v target=%q", cand.SourceIDs, cand.TargetID)
}
rowsBefore := countMemoryItems(t, store)
shortBefore := loadTidyItemRow(t, store, short.ID)
longBefore := loadTidyItemRow(t, store, long.ID)
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
}
if res.Status != TidyApplyApplied {
t.Fatalf("应用结果 = %+v, want applied", res)
}
absorbed := loadTidyItemRow(t, store, short.ID)
if absorbed.status != ingestStatusMerged || absorbed.mergedInto != long.ID {
t.Fatalf("被吸收行 = %+v, want status=merged merged_into=%s", absorbed, long.ID)
}
if absorbed.content != shortBefore.content {
t.Fatalf("被吸收行 content 被改了:%q → %q", shortBefore.content, absorbed.content)
}
survivor := loadTidyItemRow(t, store, long.ID)
if survivor.content != longBefore.content {
t.Fatalf("存活行 content 被改了:%q → %q", longBefore.content, survivor.content)
}
if survivor.mergeCount != longBefore.mergeCount+1 {
t.Fatalf("存活行 merge_count = %d, want %d", survivor.mergeCount, longBefore.mergeCount+1)
}
if !jsonHasID(t, survivor.mergedFrom, short.ID) {
t.Fatalf("存活行 merged_from = %s, want 含 %s", survivor.mergedFrom, short.ID)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("软合并改变了行数:%d → %d(行永不物理删除)", rowsBefore, got)
}
applied, err := store.GetTidyCandidate(ctx, cand.ID)
if err != nil {
t.Fatalf("GetTidyCandidate: %v", err)
}
if applied.Status != tidyCandidateApplied {
t.Fatalf("候选状态 = %q, want %q", applied.Status, tidyCandidateApplied)
}
// ② 重复应用:skipped/already_decided,且**零副作用**。
again, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("第二次 ApplyTidyCandidate 不该报错(幂等命中不是错误):%v", err)
}
if again.Status != TidyApplySkipped || again.Reason != tidySkipAlreadyDecided {
t.Fatalf("第二次应用 = %+v, want skipped/%s", again, tidySkipAlreadyDecided)
}
if len(again.AffectedIDs) != 0 {
t.Fatalf("跳过的结果不得声称改过行:%v", again.AffectedIDs)
}
if got := loadTidyItemRow(t, store, long.ID).mergeCount; got != survivor.mergeCount {
t.Fatalf("重复应用把 merge_count 又加了一次:%d → %d", survivor.mergeCount, got)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("重复应用改变了行数:%d → %d", rowsBefore, got)
}
// ③ 回滚:字段逐字复原(updated_at 除外 —— 它是「什么时候动过」的审计面)。
rev, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
}
if rev.Status != TidyApplyApplied {
t.Fatalf("回滚结果 = %+v, want applied", rev)
}
if got := loadTidyItemRow(t, store, short.ID); got != shortBefore {
t.Fatalf("回滚后被吸收行未复原:\n got=%+v\nwant=%+v", got, shortBefore)
}
if got := loadTidyItemRow(t, store, long.ID); got.mergeCount != longBefore.mergeCount || got.mergedFrom != "[]" {
t.Fatalf("回滚后存活行合并元信息未复原:%+v(want merge_count=%d merged_from=[])", got, longBefore.mergeCount)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
}
if back, err := store.GetTidyCandidate(ctx, cand.ID); err != nil || back.Status != tidyCandidatePending {
t.Fatalf("回滚后候选状态 = %q (err=%v), want pending", back.Status, err)
}
// 回滚是幂等的:再回一次 ⇒ skipped/not_applied。
if againRev, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("第二次回滚不该报错:%v", err)
} else if againRev.Status != TidyApplySkipped || againRev.Reason != tidySkipNotApplied {
t.Fatalf("第二次回滚 = %+v, want skipped/%s", againRev, tidySkipNotApplied)
}
}
// TestTidyApplyMergeUnderConcurrencyHasSingleWinner 钉住执行闸(候选状态的条件
// 更新)在**并发**下也只有一次生效:16 路同时应用同一条候选,恰好一条 applied,
// merge_count 只涨一次。少了这道闸,多路会各自把源行当作 active 再合并一次。
func TestTidyApplyMergeUnderConcurrencyHasSingleWinner(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
cand := mergeCandidateFromRun(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
const racers = 16
var (
mu sync.Mutex
applied int
skipped int
start = make(chan struct{})
wg sync.WaitGroup
)
for i := 0; i < racers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:race"})
mu.Lock()
defer mu.Unlock()
switch {
case err != nil:
t.Errorf("并发应用报错:%v", err)
case res.Status == TidyApplyApplied:
applied++
default:
skipped++
}
}()
}
close(start)
wg.Wait()
if applied != 1 || skipped != racers-1 {
t.Fatalf("并发应用 applied=%d skipped=%d, want 1/%d", applied, skipped, racers-1)
}
if got := loadTidyItemRow(t, store, long.ID).mergeCount; got != 2 {
t.Fatalf("存活行 merge_count = %d, want 2(只允许一次吸收)", got)
}
if got := loadTidyItemRow(t, store, short.ID); got.status != ingestStatusMerged || got.mergedInto != long.ID {
t.Fatalf("被吸收行 = %+v, want merged into %s", got, long.ID)
}
}
// TestTidyApplyArchiveAndRevert 钉住④:archive 是软删(status='archived',内容不变、
// 行数不变),revert 复原为 active。
//
// 本用例刻意**关掉轮末自动档**:它验的是**人工应用/回滚**这条路径,而自动档会在
// RunOnce 收尾时把这条一次性事件当场归档(那会让「apply 得到 applied」变成
// already_decided)。自动档本身在 tidy_auto_test.go 里验。
func TestTidyApplyArchiveAndRevert(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
cfg := tidyTestCfg(0.80)
cfg.DisableAutoApply = true
tr := newTestTidier(t, store, cfg, nil)
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
archives := candidatesByAction(cands, TidyArchive)
if len(archives) != 1 {
t.Fatalf("archive 候选 = %d, want 1(%+v)", len(archives), cands)
}
rowsBefore := countMemoryItems(t, store)
before := loadTidyItemRow(t, store, oneOff.ID)
if res, err := store.ApplyTidyCandidate(ctx, archives[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
} else if res.Status != TidyApplyApplied {
t.Fatalf("archive 应用 = %+v, want applied", res)
}
archived := loadTidyItemRow(t, store, oneOff.ID)
if archived.status != tidyStatusArchived {
t.Fatalf("归档后 status = %q, want %q", archived.status, tidyStatusArchived)
}
if archived.content != before.content {
t.Fatalf("软删改了 content:%q → %q", before.content, archived.content)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("软删改变了行数:%d → %d(design/23 §8:行保留)", rowsBefore, got)
}
if res, err := store.RevertTidyCandidate(ctx, archives[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
} else if res.Status != TidyApplyApplied {
t.Fatalf("archive 回滚 = %+v, want applied", res)
}
if got := loadTidyItemRow(t, store, oneOff.ID); got != before {
t.Fatalf("回滚后未复原:\n got=%+v\nwant=%+v", got, before)
}
if got := countMemoryItems(t, store); got != rowsBefore {
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
}
}
// TestTidyApplyReclassifyAndRevert 钉住④的另一半:reclassify 只改 kind 一列,
// 内容不变;revert 把 kind 还原成候选证据里的旧值。
func TestTidyApplyReclassifyAndRevert(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
misclassified := MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
}
superset := habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)
bad := seedTidyItem(t, store, misclassified)
seedTidyItem(t, store, superset)
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
fixtureHabitShort: {1, 0},
fixtureHabitLong: simVector(0.95),
}})
if _, err := tr.RunOnce(ctx); err != nil {
t.Fatalf("RunOnce: %v", err)
}
cands, err := store.ListTidyCandidates(ctx, "", 50)
if err != nil {
t.Fatalf("ListTidyCandidates: %v", err)
}
res := candidatesByAction(cands, TidyReclassify)
if len(res) != 1 {
t.Fatalf("reclassify 候选 = %d, want 1(%+v)", len(res), cands)
}
before := loadTidyItemRow(t, store, bad.ID)
if out, err := store.ApplyTidyCandidate(ctx, res[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("ApplyTidyCandidate: %v", err)
} else if out.Status != TidyApplyApplied {
t.Fatalf("reclassify 应用 = %+v, want applied", out)
}
after := loadTidyItemRow(t, store, bad.ID)
if after.kind != string(KindRelationship) {
t.Fatalf("reclassify 后 kind = %q, want %q", after.kind, KindRelationship)
}
if after.content != before.content || after.status != before.status {
t.Fatalf("reclassify 改了不该改的列:%+v(before=%+v)", after, before)
}
if _, err := store.RevertTidyCandidate(ctx, res[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
t.Fatalf("RevertTidyCandidate: %v", err)
}
if got := loadTidyItemRow(t, store, bad.ID); got.kind != before.kind {
t.Fatalf("回滚后 kind = %q, want %q(还原成候选证据里的旧值)", got.kind, before.kind)
}
}
// TestTidyApplyLinkIsNotApplyable 钉住⑤:link 没有执行语义(design/23 §14:
// 链接的存储形态未定),应用它只能得到 skipped,且**零改动**。
//
// 2026-10-07 起规则层**不再产出** link 候选(判 keep,见 tidy_rules.go),因此这里
// 手工落一条历史形状的 link 候选来钉这条执行面口径 —— 库里既有的 link 行仍必须
// 得到「不可应用」的诚实回答(它们的出路是按动作批量拒绝,见 tidy_reject_test.go)。
func TestTidyApplyLinkIsNotApplyable(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
redBull := seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95))
iced := seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95))
link := TidyCandidate{
ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Action: TidyLink, SourceIDs: []string{redBull.ID, iced.ID},
Reason: tidyReasonSimilarNotContained,
Evidence: TidyEvidence{
Similarity: 0.99, RecallMode: tidyRecallEmbedding, Containment: string(containNone),
ContentA: fixtureEnergyRedBull, ContentB: fixtureEnergyIced,
},
RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base,
}
if created, err := store.insertTidyCandidate(ctx, link); err != nil || !created {
t.Fatalf("落 link 候选 = (%t, %v), want (true, nil)", created, err)
}
stored, err := store.GetTidyCandidate(ctx, link.ID)
if err != nil {
t.Fatalf("GetTidyCandidate: %v", err)
}
before := dumpTidyTable(t, store, "memory_items", memoryItemsColumns)
out, err := store.ApplyTidyCandidate(ctx, stored.ID, TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("ApplyTidyCandidate(link): %v", err)
}
if out.Status != TidyApplySkipped || out.Reason != tidySkipActionNotApplyable {
t.Fatalf("link 应用结果 = %+v, want skipped/%s", out, tidySkipActionNotApplyable)
}
if len(out.AffectedIDs) != 0 {
t.Fatalf("不可应用的动作不得声称改过行:%v", out.AffectedIDs)
}
if got := dumpTidyTable(t, store, "memory_items", memoryItemsColumns); len(got) != len(before) {
t.Fatalf("link 应用改动了记忆:%d → %d 行", len(before), len(got))
}
if cand, err := store.GetTidyCandidate(ctx, stored.ID); err != nil || cand.Status != tidyCandidatePending {
t.Fatalf("不可应用的候选应保持 pending,实际 %q (err=%v)", cand.Status, err)
}
}
// TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated 钉住⑥:按 run 批量应用
// 逐条给结果、上限生效、超限如实报 Truncated + Total,并把该轮留痕的 applied 累加。
//
// 本用例刻意**关掉轮末自动档**:它验的是批量应用这条**人工**入口的粒度与上限,
// 而自动档会先把这 5 条一次性事件落地(那会让 Total 变成 0)。
func TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated(t *testing.T) {
t.Parallel()
ctx := context.Background()
store := newTestStore(t)
base := tidyTestTime(t)
const oneOffs = 5
ids := make([]string, 0, oneOffs)
for i := 0; i < oneOffs; i++ {
item := habitItem(fixtureOneOffEvent, base.Add(time.Duration(i)*time.Minute), 0.9)
ids = append(ids, seedTidyItem(t, store, item).ID)
}
cfg := tidyTestCfg(0.80)
cfg.DisableAutoApply = true
tr := newTestTidier(t, store, cfg, nil)
stats, err := tr.RunOnce(ctx)
if err != nil {
t.Fatalf("RunOnce: %v", err)
}
if stats.ByAction[string(TidyArchive)] != oneOffs {
t.Fatalf("ByAction[archive] = %d, want %d(%+v)", stats.ByAction[string(TidyArchive)], oneOffs, stats.ByAction)
}
batch, err := store.ApplyTidyCandidates(ctx, stats.RunID, TidyApplyOptions{Actor: "owner:test", Limit: 2})
if err != nil {
t.Fatalf("ApplyTidyCandidates: %v", err)
}
if batch.Total != oneOffs || len(batch.Results) != 2 || !batch.Truncated {
t.Fatalf("批量回执 = %+v, want total=%d results=2 truncated=true", batch, oneOffs)
}
if batch.Applied != 2 || batch.Archived != 2 || batch.Skipped != 0 || batch.Failed != 0 {
t.Fatalf("批量计数 = %+v, want applied=2 archived=2", batch)
}
for _, res := range batch.Results {
if res.Status != TidyApplyApplied || res.Action != TidyArchive || len(res.AffectedIDs) != 1 {
t.Fatalf("逐条结果 = %+v, want 每条 applied/archive 带 1 个 affected id", res)
}
}
archived := 0
for _, id := range ids {
if loadTidyItemRow(t, store, id).status == tidyStatusArchived {
archived++
}
}
if archived != 2 {
t.Fatalf("实际归档 = %d, want 2", archived)
}
runs, err := store.ListTidyRuns(ctx, 10)
if err != nil {
t.Fatalf("ListTidyRuns: %v", err)
}
if len(runs) != 1 || runs[0].Applied != 2 {
t.Fatalf("运行留痕 applied = %d (runs=%d), want 2", runs[0].Applied, len(runs))
}
// 剩余 3 条:第二批应用,超限标记消失(Total 已 ≤ 上限)。
batch2, err := store.ApplyTidyCandidates(ctx, stats.RunID, TidyApplyOptions{Actor: "owner:test", Limit: 2})
if err != nil {
t.Fatalf("第二批: %v", err)
}
if batch2.Total != oneOffs-2 || !batch2.Truncated || batch2.Applied != 2 {
t.Fatalf("第二批回执 = %+v, want total=3 truncated=true applied=2", batch2)
}
// runId 拼错必须是「查不到这一轮」,不是「这一轮没有待定候选」。
if _, err := store.ApplyTidyCandidates(ctx, "no-such-run", TidyApplyOptions{Actor: "owner:test"}); !errors.Is(err, ErrTidyRunRecordNotFound) {
t.Fatalf("未知 runId 的 err = %v, want ErrTidyRunRecordNotFound", err)
}
// runId 为空 ⇒ 「全部待定应用」这一档(没有轮次筛)。
rest, err := store.ApplyTidyCandidates(ctx, "", TidyApplyOptions{Actor: "owner:test"})
if err != nil {
t.Fatalf("全部待定应用: %v", err)
}
if rest.Total != 1 || rest.Applied != 1 || rest.Archived != 1 {
t.Fatalf("全部待定回执 = %+v, want total=1 applied=1 archived=1", rest)
}
}
// jsonHasID 报告一个 JSON 数组文本里是否含 id。
func jsonHasID(t *testing.T, jsonText, id string) bool {
t.Helper()
var ids []string
if err := json.Unmarshal([]byte(jsonText), &ids); err != nil {
t.Fatalf("解析 %q: %v", jsonText, err)
}
return containsID(ids, id)
}