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) } }