258 lines
8.9 KiB
Go
258 lines
8.9 KiB
Go
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
|
||
}
|
||
|
||
// 向量回填相关方法:近邻服务本身用不到,这里只需满足接口。
|
||
// 回填链路的测试在 handler 层(backstage_embeddings)与本文件的 key 分支单测里覆盖。
|
||
func (f *fakeEmbeddingRepo) ListPendingEmbeddings(context.Context, string, int, int) ([]repository.PendingEmbedding, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func (f *fakeEmbeddingRepo) CountPendingEmbeddings(context.Context, string, int) (int, error) {
|
||
return 0, nil
|
||
}
|
||
|
||
func (f *fakeEmbeddingRepo) RecordEmbeddingFailure(context.Context, string, uint32, string) 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)
|
||
}
|
||
}
|