package memory import ( "context" "errors" "testing" "time" ) // 本文件锁定整理候选的**按动作批量拒绝**(tidy_reject.go):给没有执行语义的候选 // (典型是 link,活库曾积压 501 条)一条干净的出路。四条判据: // // ① 只动指定动作的 pending 行,**不碰其他动作、不碰已决定的行**; // ② 幂等:重复调用第二次是 0 条,不是错误; // ③ 上限生效、超限如实报 total/truncated; // ④ 动作名不在闭集内 ⇒ 分类错误(调用方据此回 400),绝不静默改 0 条。 // insertLinkCandidate 落一条 link 候选(历史形状:无执行语义)。 func insertLinkCandidate(t *testing.T, store *Store, ids []string) TidyCandidate { t.Helper() c := TidyCandidate{ ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Action: TidyLink, SourceIDs: ids, Reason: tidyReasonSimilarNotContained, Evidence: TidyEvidence{ Similarity: 0.9, Containment: string(containNone), ContentA: fixtureEnergyRedBull, ContentB: fixtureEnergyIced, }, RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: tidyTestTime(t), } if created, err := store.insertTidyCandidate(context.Background(), c); err != nil || !created { t.Fatalf("落 link 候选 = (%t, %v), want (true, nil)", created, err) } return c } // TestTidyRejectByActionIsIdempotentAndScoped 是判据 ①②:只决定指定动作的 pending 行, // 重复调用第二次 0 条;其他动作的候选与记忆行**一行不动**。 func TestTidyRejectByActionIsIdempotentAndScoped(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) items := make([]string, 0, 4) for i := 0; i < 4; i++ { items = append(items, seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9)).ID) } // 两条 link 候选的 source_ids 必须不同:幂等键含 source_ids,否则第二条会被 // INSERT OR IGNORE 静默挡下(那是幂等的正确行为,但会让夹具失效)。 links := []TidyCandidate{ insertLinkCandidate(t, store, items[0:2]), insertLinkCandidate(t, store, items[2:4]), } // 一条**别的动作**的候选:它必须原封不动。 seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base.Add(time.Minute), 0.9)) other := TidyCandidate{ ID: mustNewTidyID(), PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship, Action: TidyArchive, SourceIDs: []string{"mem-nonexistent"}, Reason: tidyReasonOneOffEvent, RuleVersion: TidyRuleVersion, Status: tidyCandidatePending, CreatedAt: base, } if created, err := store.insertTidyCandidate(ctx, other); err != nil || !created { t.Fatalf("落 archive 候选 = (%t, %v)", created, err) } itemsBefore := countMemoryItems(t, store) batch, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test", Limit: 10}) if err != nil { t.Fatalf("RejectTidyCandidatesByAction: %v", err) } if batch.Total != 2 || batch.Rejected != 2 || batch.Truncated { t.Fatalf("回执 = %+v, want total=2 rejected=2 truncated=false", batch) } for _, c := range links { got, err := store.GetTidyCandidate(ctx, c.ID) if err != nil { t.Fatalf("GetTidyCandidate: %v", err) } if got.Status != tidyCandidateRejected { t.Fatalf("link 候选状态 = %q, want %q", got.Status, tidyCandidateRejected) } } if got, err := store.GetTidyCandidate(ctx, other.ID); err != nil || got.Status != tidyCandidatePending { t.Fatalf("别的动作的候选被动了:%q (err=%v)", got.Status, err) } // 拒绝是**决定**不是删除:记忆一行不动、正文不动。 if got := countMemoryItems(t, store); got != itemsBefore { t.Fatalf("memory_items 行数 %d → %d,拒绝不该碰记忆", itemsBefore, got) } pending, err := store.ListTidyCandidates(ctx, tidyCandidatePending, 50) if err != nil { t.Fatalf("ListTidyCandidates: %v", err) } if len(pending) != 1 || pending[0].ID != other.ID { t.Fatalf("待办队列 = %+v, want 只剩那条 archive(噪声被清掉)", pending) } // 幂等:再来一次,0 条且不是错误。 again, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test"}) if err != nil { t.Fatalf("第二次 RejectTidyCandidatesByAction: %v", err) } if again.Rejected != 0 || again.Total != 0 || again.Truncated { t.Fatalf("第二次回执 = %+v, want 全 0(幂等)", again) } } // TestTidyRejectByActionHonoursLimitAndRejectsUnknownAction 是判据 ③④: // 上限生效且超限如实报 truncated;动作名不在闭集内返回分类错误。 func TestTidyRejectByActionHonoursLimitAndRejectsUnknownAction(t *testing.T) { t.Parallel() ctx := context.Background() store := newTestStore(t) base := tidyTestTime(t) items := make([]string, 0, 4) for i := 0; i < 4; i++ { items = append(items, seedTidyItem(t, store, habitItem(fixtureDupText, base, 0.9)).ID) } // 三条 link 候选各用不同的 source_ids(幂等键含 source_ids,见上一个用例)。 for i := 0; i < 3; i++ { insertLinkCandidate(t, store, items[i:i+2]) } batch, err := store.RejectTidyCandidatesByAction(ctx, TidyLink, TidyApplyOptions{Actor: "owner:test", Limit: 2}) if err != nil { t.Fatalf("RejectTidyCandidatesByAction: %v", err) } if batch.Limit != 2 || batch.Total != 3 || batch.Rejected != 2 || !batch.Truncated { t.Fatalf("回执 = %+v, want limit=2 total=3 rejected=2 truncated=true", batch) } if _, err := store.RejectTidyCandidatesByAction(ctx, TidyAction("delete-everything"), TidyApplyOptions{Actor: "owner:test"}); !errors.Is(err, ErrTidyUnknownAction) { t.Fatalf("未知动作的 err = %v, want ErrTidyUnknownAction", err) } }