This commit is contained in:
toom1996
2026-10-18 17:42:10 +08:00
parent 1b06bd5bda
commit 1de1ec46a6
58 changed files with 5281 additions and 19191 deletions

View File

@ -0,0 +1,243 @@
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)
}
}