Files
backend_v2/internal/service/similar_service_test.go
toom1996 1de1ec46a6 update
2026-10-18 17:42:10 +08:00

244 lines
8.3 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 service
import (
"context"
"errors"
"testing"
"fashionapi/internal/repository"
)
// fakeEmbeddingRepo 是 EmbeddingRepository 的可控替身,记录被调用的参数。
type fakeEmbeddingRepo struct {
vecs map[uint32][]float32
neighbors map[uint32][]repository.SimilarImage
pairs map[uint32][]repository.SimilarNeighbor
dupStats map[string]map[uint32]repository.DupStat
listErr error
gotKind string
gotQueryID uint32
gotExclude []uint32
gotMinSim float64
gotLimit int
replacedSelfIDs []uint32
replacedRows []repository.SimilarPair
replaceErr error
}
func (f *fakeEmbeddingRepo) UpsertEmbedding(context.Context, string, uint32, []float32) error {
return nil
}
func (f *fakeEmbeddingRepo) BulkUpsertEmbedding(context.Context, string, []uint32, [][]float32) error {
return nil
}
func (f *fakeEmbeddingRepo) ListEmbeddings(_ context.Context, _ string, imageIDs []uint32) (map[uint32][]float32, error) {
if f.listErr != nil {
return nil, f.listErr
}
out := map[uint32][]float32{}
for _, id := range imageIDs {
if v, ok := f.vecs[id]; ok {
out[id] = v
}
}
return out, nil
}
func (f *fakeEmbeddingRepo) FindSimilarImages(_ context.Context, kind string, imageID uint32, excludeIDs []uint32, _ []float32, minSim float64, limit int) ([]repository.SimilarImage, error) {
f.gotKind = kind
f.gotQueryID = imageID
f.gotExclude = excludeIDs
f.gotMinSim = minSim
f.gotLimit = limit
return f.neighbors[imageID], nil
}
func (f *fakeEmbeddingRepo) ReplaceSimilarPairs(_ context.Context, _ string, selfIDs []uint32, rows []repository.SimilarPair) error {
f.replacedSelfIDs = selfIDs
f.replacedRows = rows
return f.replaceErr
}
func (f *fakeEmbeddingRepo) ListSimilarPairs(_ context.Context, _ string, imageIDs []uint32) (map[uint32][]repository.SimilarNeighbor, error) {
if f.listErr != nil {
return nil, f.listErr
}
out := map[uint32][]repository.SimilarNeighbor{}
for _, id := range imageIDs {
if ns, ok := f.pairs[id]; ok {
out[id] = ns
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
func (f *fakeEmbeddingRepo) DupStatsByOwner(_ context.Context, kind string) (map[uint32]repository.DupStat, error) {
if f.listErr != nil {
return nil, f.listErr
}
return f.dupStats[kind], nil
}
// TestSimilarDisabledReturnsNothing 未启用(仓储为 nil)时必须安全返回空结果,不 panic。
func TestSimilarDisabledReturnsNothing(t *testing.T) {
svc := NewSimilarService(nil, 0.93)
if svc.Enabled() {
t.Fatal("未注入仓储时应为未启用")
}
if got := svc.HintsForImages(context.Background(), "runway", []uint32{1, 2}); got != nil {
t.Fatalf("未启用应返回 nil,得到 %v", got)
}
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
t.Fatalf("未启用时预计算应直接返回 nil,得到 %v", err)
}
}
// TestHintsReadsStoredPairs 页面渲染只读结果表:验证分组、降序、每张上限,以及字段透传。
func TestHintsReadsStoredPairs(t *testing.T) {
nb := func(id uint32, sim float64) repository.SimilarNeighbor {
return repository.SimilarNeighbor{
OtherID: id, OwnerID: id * 10, Similar: sim,
Image: "runway/x.jpg", Name: "Look 1", OwnerTitle: "Fall 2019 Menswear",
}
}
repo := &fakeEmbeddingRepo{pairs: map[uint32][]repository.SimilarNeighbor{
// 故意乱序且超过上限(6 条),应被降序 + 截到 5 条。
7: {nb(71, 0.95), nb(72, 0.99), nb(73, 0.91), nb(74, 0.98), nb(75, 0.92), nb(76, 0.90)},
}}
svc := NewSimilarService(repo, 0.93)
got := svc.HintsForImages(context.Background(), "runway", []uint32{7, 8})
if len(got) != 1 {
t.Fatalf("应只有图片 7 有提示,得到 %d 项", len(got))
}
hints := got[7]
if len(hints) != 5 {
t.Fatalf("每张最多 5 条提示,得到 %d", len(hints))
}
for i := 1; i < len(hints); i++ {
if hints[i].Similar > hints[i-1].Similar {
t.Fatalf("提示应按相似度降序,实际 %+v", hints)
}
}
if hints[0].ImageID != 72 || hints[0].Similar != 0.99 {
t.Fatalf("第一条应为相似度最高的 72,实际 %+v", hints[0])
}
if hints[0].Image != "runway/x.jpg" || hints[0].Name != "Look 1" || hints[0].OwnerTitle != "Fall 2019 Menswear" {
t.Fatalf("近邻描述字段未透传: %+v", hints[0])
}
if hints[0].OwnerID != 720 {
t.Fatalf("近邻所属图集 id 未透传: %+v", hints[0])
}
}
// TestHintsReadErrorDegradesQuietly 读结果表失败绝不能让页面渲染失败:返回空、不 panic。
func TestHintsReadErrorDegradesQuietly(t *testing.T) {
repo := &fakeEmbeddingRepo{listErr: errors.New("db down")}
svc := NewSimilarService(repo, 0.93)
if got := svc.HintsForImages(context.Background(), "street", []uint32{1}); got != nil {
t.Fatalf("读取失败应返回 nil,得到 %v", got)
}
}
// TestPrecomputeWritesBidirectionalPairs 预计算:逐张查近邻,并把结果**双向**落库;
// 同时验证阈值、排除集(整个图集)与上限都透传给仓储。
func TestPrecomputeWritesBidirectionalPairs(t *testing.T) {
repo := &fakeEmbeddingRepo{
vecs: map[uint32][]float32{1: {0.1, 0.2}, 2: {0.3, 0.4}},
neighbors: map[uint32][]repository.SimilarImage{
1: {{ImageID: 9, OwnerID: 100, Similar: 0.97}},
},
}
svc := NewSimilarService(repo, 0.93)
// 图集含图片 1、2;其中只有 1 有近邻 9(属于别的图集)。
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
t.Fatalf("预计算失败: %v", err)
}
if repo.gotMinSim != 0.93 {
t.Fatalf("阈值应透传给仓储,实际 %v", repo.gotMinSim)
}
if repo.gotLimit != 5 {
t.Fatalf("每张最多取 5 条近邻,实际 %d", repo.gotLimit)
}
// 排除集必须是整个图集:同图集内不比(同场共享背景/灯光/造型,相似度天然偏高)。
if len(repo.gotExclude) != 2 || repo.gotExclude[0] != 1 || repo.gotExclude[1] != 2 {
t.Fatalf("同图集排除集应为整个图集的图片 id,实际 %v", repo.gotExclude)
}
// 双向:1→9 与 9→1 各一行。
if len(repo.replacedRows) != 2 {
t.Fatalf("应写入双向共 2 行,实际 %+v", repo.replacedRows)
}
forward, backward := false, false
for _, r := range repo.replacedRows {
switch {
case r.SelfID == 1 && r.OtherID == 9:
forward = true
case r.SelfID == 9 && r.OtherID == 1:
backward = true
}
}
if !forward || !backward {
t.Fatalf("必须双向写入(A→B 与 B→A),实际 %+v", repo.replacedRows)
}
// 替换范围是本图集的图片 id(先删后插用)。
if len(repo.replacedSelfIDs) != 2 {
t.Fatalf("替换范围应为整个图集的图片 id,实际 %v", repo.replacedSelfIDs)
}
}
// TestPrecomputeNoVectorsSkips 图集还没有向量(视觉服务未启用)时不做任何计算与写入。
func TestPrecomputeNoVectorsSkips(t *testing.T) {
repo := &fakeEmbeddingRepo{vecs: map[uint32][]float32{}}
svc := NewSimilarService(repo, 0.93)
if err := svc.PrecomputeForGallery(context.Background(), "runway", []uint32{1, 2}); err != nil {
t.Fatalf("无向量时应静默返回,实际 %v", err)
}
if repo.replacedRows != nil || repo.replacedSelfIDs != nil {
t.Fatalf("无向量时不应写库,实际 rows=%v selfIDs=%v", repo.replacedRows, repo.replacedSelfIDs)
}
}
// TestDupStatsByKind 列表页要用的汇总:按 kind 分组返回图集的重复概况与总图数。
func TestDupStatsByKind(t *testing.T) {
repo := &fakeEmbeddingRepo{dupStats: map[string]map[uint32]repository.DupStat{
"runway": {7: {Total: 120, Images: 3, FirstImageID: 55}},
}}
svc := NewSimilarService(repo, 0.93)
got := svc.DupStats(context.Background())
st, ok := got["runway"][7]
if !ok {
t.Fatalf("应返回 runway 图集 7 的概况,实际 %+v", got)
}
if st.Total != 120 || st.Images != 3 || st.FirstImageID != 55 {
t.Fatalf("概况字段不正确: %+v", st)
}
}
// TestDupStatsErrorDegradesQuietly 汇总失败只能记日志:列表页必须照常渲染。
func TestDupStatsErrorDegradesQuietly(t *testing.T) {
repo := &fakeEmbeddingRepo{listErr: errors.New("db down")}
svc := NewSimilarService(repo, 0.93)
if got := svc.DupStats(context.Background()); len(got) != 0 {
t.Fatalf("汇总失败应返回空,得到 %+v", got)
}
}
// TestDupStatsDisabled 未启用时直接返回 nil,不触发任何查询。
func TestDupStatsDisabled(t *testing.T) {
svc := NewSimilarService(nil, 0.93)
if got := svc.DupStats(context.Background()); got != nil {
t.Fatalf("未启用应返回 nil,得到 %+v", got)
}
}