Files
backend_fiesta/services/scan_test.go
2026-09-16 11:52:35 +05:30

556 lines
22 KiB
Go

package services
import (
"context"
"errors"
"testing"
"nearle/models"
"nearle/repositories"
)
/*
The scan pipeline has three decisions worth defending: which catalogue rows
count as "the product", which of the customer's outlets get shown and in what
order, and what happens when the outlet they tapped has run out. Everything
below drives those through a fake repository; the SQL itself is exercised
against a real database in scratch/ when there is one.
*/
// fakeScanRepo answers from fixtures and records what it was asked.
type fakeScanRepo struct {
exists bool
homeLat float64
homeLng float64
homeOK bool
stores []models.ScanStore
text []repositories.CatalogueHit
vector []repositories.CatalogueHit
hasVec bool
options []repositories.StoreOptionRow
at map[int]*repositories.StoreOptionRow // productid → row
askedKeys []repositories.CatalogueKey
askedNames []string
askedLocs []int
cachedHits map[string][]repositories.CatalogueHit
}
func (f *fakeScanRepo) CustomerExists(context.Context, int) (bool, error) { return f.exists, nil }
func (f *fakeScanRepo) CustomerHome(context.Context, int) (float64, float64, bool, error) {
return f.homeLat, f.homeLng, f.homeOK, nil
}
func (f *fakeScanRepo) RegisteredStores(context.Context, int) ([]models.ScanStore, error) {
out := make([]models.ScanStore, len(f.stores))
copy(out, f.stores)
return out, nil
}
func (f *fakeScanRepo) StoreOptions(_ context.Context, locs []int, keys []repositories.CatalogueKey, names []string) ([]repositories.StoreOptionRow, error) {
f.askedLocs, f.askedKeys, f.askedNames = locs, keys, names
allowed := make(map[int]bool)
for _, l := range locs {
allowed[l] = true
}
var out []repositories.StoreOptionRow
for _, o := range f.options {
if allowed[o.Locationid] {
out = append(out, o)
}
}
return out, nil
}
func (f *fakeScanRepo) ProductAt(_ context.Context, _, _, productid int) (*repositories.StoreOptionRow, error) {
return f.at[productid], nil
}
func (f *fakeScanRepo) VectorSearch(context.Context, []float32, int) ([]repositories.CatalogueHit, error) {
return f.vector, nil
}
func (f *fakeScanRepo) TextSearch(context.Context, string, int) ([]repositories.CatalogueHit, error) {
return f.text, nil
}
func (f *fakeScanRepo) VectorSearchAvailable() bool { return f.hasVec }
func (f *fakeScanRepo) CachedVector(context.Context, string, string) ([]float32, bool) {
return nil, false
}
func (f *fakeScanRepo) CacheVector(context.Context, string, string, []float32) {}
func (f *fakeScanRepo) CachedHits(_ context.Context, method, label string) ([]repositories.CatalogueHit, bool) {
h, ok := f.cachedHits[method+"|"+label]
return h, ok
}
func (f *fakeScanRepo) CacheHits(_ context.Context, method, label string, hits []repositories.CatalogueHit) {
if f.cachedHits == nil {
f.cachedHits = make(map[string][]repositories.CatalogueHit)
}
f.cachedHits[method+"|"+label] = hits
}
type fakeEmbedder struct {
vec []float32
err error
}
func (e fakeEmbedder) Embed(context.Context, string) ([]float32, error) { return e.vec, e.err }
func (e fakeEmbedder) Model() string { return "fake-model" }
// A customer in Peelamedu with three outlets: one 1 km away, one 4 km away,
// one across town with no coordinates on file.
func fixtureStores() []models.ScanStore {
return []models.ScanStore{
{Tenantid: 1, Tenantname: "Suriya Store", Locationid: 10, Locationname: "Peelamedu", Latitude: 11.030, Longitude: 77.030},
{Tenantid: 2, Tenantname: "R Mart", Locationid: 20, Locationname: "Hopes", Latitude: 11.010, Longitude: 77.000},
{Tenantid: 3, Tenantname: "Daily Needs", Locationid: 30, Locationname: "Gandhipuram"},
}
}
var milkBikis = repositories.CatalogueHit{Brand: "britannia", ID: 7, ProductName: "Milk Bikis", Size: "100 g", VariantKey: "milk_bikis", ImageID: "britannia_milk_bikis_100g", Distance: 0.05}
var milkBikis200 = repositories.CatalogueHit{Brand: "britannia", ID: 8, ProductName: "Milk Bikis", Size: "200 g", VariantKey: "milk_bikis", ImageID: "britannia_milk_bikis_200g", Distance: 0.12}
var goodDay = repositories.CatalogueHit{Brand: "britannia", ID: 9, ProductName: "Good Day Butter", VariantKey: "good_day", ImageID: "britannia_good_day", Distance: 0.40}
func newLookupFixture() *fakeScanRepo {
return &fakeScanRepo{
exists: true,
stores: fixtureStores(),
hasVec: true,
vector: []repositories.CatalogueHit{milkBikis, milkBikis200, goodDay},
text: []repositories.CatalogueHit{milkBikis},
options: []repositories.StoreOptionRow{
// Nearest outlet: sells it, but the shelf is empty.
{Tenantid: 1, Locationid: 10, Productid: 100, Productname: "Milk Bikis 100g", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Unitvalue: "100", Productunit: "g", Price: 10, Stock: 0},
// 4 km away: has both sizes.
{Tenantid: 2, Locationid: 20, Productid: 200, Productname: "Milk Bikis 100g", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Unitvalue: "100", Productunit: "g", Price: 12, Stock: 6},
{Tenantid: 2, Locationid: 20, Productid: 201, Productname: "Milk Bikis 200g", Productbrand: "britannia", Catalogueid: 8, Imageid: "britannia_milk_bikis_200g", Unitvalue: "200", Productunit: "g", Price: 22, Stock: 3},
// Same product listed again as a size under the 100g row — must not
// appear twice.
{Tenantid: 2, Locationid: 20, Productid: 201, Productname: "Milk Bikis 200g", Productbrand: "britannia", Catalogueid: 8, Imageid: "britannia_milk_bikis_200g", Unitvalue: "200", Productunit: "g", Price: 22, Stock: 3, Parentid: 200, Variantname: "200 g"},
// Unknown distance, in stock.
{Tenantid: 3, Locationid: 30, Productid: 300, Productname: "Milk Bikis", Productbrand: "britannia", Catalogueid: 7, Imageid: "britannia_milk_bikis_100g", Price: 11, Stock: 2},
},
}
}
func TestLookupRecommendsTheNearestOutletWithStock(t *testing.T) {
repo := newLookupFixture()
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1, 0.2}})
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
Customerid: 5, Label: "Milk Bikis", Latitude: "11.035", Longitude: "77.035",
})
if err != nil {
t.Fatal(err)
}
if resp.Match == nil || resp.Match.ProductName != "Milk Bikis" || resp.Match.Catalogueid != 7 {
t.Fatalf("expected Milk Bikis 100 g as the match, got %+v", resp.Match)
}
if resp.Match.Method != "vector+text" {
t.Errorf("method = %q, want vector+text", resp.Match.Method)
}
if len(resp.Variants) != 2 {
t.Errorf("the catalogue family should be the two Milk Bikis sizes, got %d: %+v", len(resp.Variants), resp.Variants)
}
if !resp.Available || resp.RecommendedLocationid != 20 {
t.Fatalf("Hopes (4 km, in stock) should be recommended over Peelamedu (1 km, empty); got available=%v recommended=%d", resp.Available, resp.RecommendedLocationid)
}
// Order: in stock first (Hopes, then Gandhipuram with no distance), then
// the empty nearest outlet.
var order []int
for _, o := range resp.Stores {
order = append(order, o.Locationid)
}
if len(order) != 3 || order[0] != 20 || order[1] != 30 || order[2] != 10 {
t.Fatalf("store order = %v, want [20 30 10]", order)
}
if !resp.Stores[0].Recommended || resp.Stores[1].Recommended || resp.Stores[2].Recommended {
t.Error("exactly the first in-stock offer should be recommended")
}
if resp.Stores[2].Available {
t.Error("Peelamedu has no stock and must not be available")
}
if resp.Stores[1].DistanceKm != -1 {
t.Errorf("an outlet with no coordinates reports distance -1, got %v", resp.Stores[1].DistanceKm)
}
if resp.Stores[0].DistanceKm <= 0 || resp.Stores[0].DistanceKm > 10 {
t.Errorf("Hopes should be a few km away, got %v", resp.Stores[0].DistanceKm)
}
hopes := resp.Stores[0]
if len(hopes.Options) != 2 {
t.Fatalf("Hopes should offer two sizes once, got %d: %+v", len(hopes.Options), hopes.Options)
}
if hopes.Options[0].Productid != 200 || hopes.Options[0].MatchedBy != "imageid" || hopes.Options[0].Size != "100 g" {
t.Errorf("first option should be the direct 100 g match by imageid, got %+v", hopes.Options[0])
}
if hopes.Options[1].Productid != 201 || hopes.Options[1].IsVariant {
t.Errorf("the 200 g row seen both directly and as a size keeps the direct form, got %+v", hopes.Options[1])
}
// Every catalogue size was asked for at every registered outlet.
if len(repo.askedKeys) != 2 || len(repo.askedLocs) != 3 {
t.Errorf("asked keys=%d locs=%d, want 2 and 3", len(repo.askedKeys), len(repo.askedLocs))
}
}
func TestLookupFallsBackToTextWhenTheModelFails(t *testing.T) {
repo := newLookupFixture()
svc := NewScanService(repo, fakeEmbedder{err: errors.New("429 rate limited")})
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"})
if err != nil {
t.Fatal(err)
}
if resp.Match == nil || resp.Match.Method != "text" || resp.Match.Catalogueid != 7 {
t.Fatalf("expected a text-only match on Milk Bikis, got %+v", resp.Match)
}
if resp.Confidence < 0.9 {
t.Errorf("the label is the whole product name; confidence should be high, got %v", resp.Confidence)
}
}
func TestLookupWithoutAnEmbedderIsTextOnly(t *testing.T) {
repo := newLookupFixture()
repo.hasVec = false
svc := NewScanService(repo, nil)
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "milk bikis"})
if err != nil {
t.Fatal(err)
}
if resp.Match == nil || resp.Match.Method != "text" {
t.Fatalf("expected text method, got %+v", resp.Match)
}
}
func TestLookupUsesTheCachedRankingOnASecondScan(t *testing.T) {
repo := newLookupFixture()
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"}); err != nil {
t.Fatal(err)
}
// Take the catalogue away: the second scan must be served from cache.
repo.vector, repo.text = nil, nil
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"})
if err != nil {
t.Fatal(err)
}
if resp.Match == nil || resp.Match.Catalogueid != 7 {
t.Fatalf("second scan should hit the cache, got %+v", resp.Match)
}
}
func TestLookupRefusesAWeakMatch(t *testing.T) {
repo := newLookupFixture()
repo.vector = []repositories.CatalogueHit{{Brand: "x", ID: 1, ProductName: "Something Else", Distance: 0.9}}
repo.text = nil
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "zzz"})
if err != nil {
t.Fatal(err)
}
if resp.Match != nil || resp.Available || len(resp.Stores) != 0 {
t.Fatalf("a 0.1 similarity is not a match; got %+v", resp)
}
if repo.askedLocs != nil {
t.Error("no outlet should be queried without a match")
}
}
func TestLookupVerifiesTheAppsTenantList(t *testing.T) {
repo := newLookupFixture()
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
// The app says tenant 2 and tenant 99; 99 is not registered.
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
Customerid: 5, Label: "Milk Bikis", Tenantids: []int{2, 99},
})
if err != nil {
t.Fatal(err)
}
if len(resp.UnregisteredTenantids) != 1 || resp.UnregisteredTenantids[0] != 99 {
t.Errorf("99 should be reported as unregistered, got %v", resp.UnregisteredTenantids)
}
if len(resp.Stores) != 1 || resp.Stores[0].Tenantid != 2 {
t.Errorf("only tenant 2's outlet should be offered, got %+v", resp.Stores)
}
// A list that matches nothing is stale, not a request for nothing.
resp, _ = svc.Lookup(context.Background(), models.ScanLookupRequest{
Customerid: 5, Label: "Milk Bikis", Tenantids: []int{98, 99},
})
if len(resp.Stores) != 3 || len(resp.UnregisteredTenantids) != 2 {
t.Errorf("a wholly stale list falls back to every registered outlet, got %d stores / %v", len(resp.Stores), resp.UnregisteredTenantids)
}
}
func TestLookupWithNoStoresStillReturnsTheMatch(t *testing.T) {
repo := newLookupFixture()
repo.stores = nil
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Milk Bikis"})
if err != nil {
t.Fatal(err)
}
if resp.Match == nil || resp.Available || len(resp.Stores) != 0 {
t.Fatalf("match without stores, got %+v", resp)
}
}
func TestLookupRejectsBadInput(t *testing.T) {
svc := NewScanService(newLookupFixture(), nil)
if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Label: "x"}); !errors.Is(err, ErrScanBadRequest) {
t.Errorf("missing customerid: %v", err)
}
if _, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 1, Label: " "}); !errors.Is(err, ErrScanBadRequest) {
t.Errorf("blank label: %v", err)
}
repo := newLookupFixture()
repo.exists = false
if _, err := NewScanService(repo, nil).Lookup(context.Background(), models.ScanLookupRequest{Customerid: 1, Label: "x"}); !errors.Is(err, ErrScanCustomerNotFound) {
t.Errorf("unknown customer: %v", err)
}
}
func TestConfirmHoldsWhenStockIsThere(t *testing.T) {
repo := newLookupFixture()
repo.at = map[int]*repositories.StoreOptionRow{200: &repo.options[1]}
svc := NewScanService(repo, nil)
resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{
Customerid: 5, Tenantid: 2, Locationid: 20, Productid: 200, Quantity: 4,
})
if err != nil {
t.Fatal(err)
}
if !resp.Ok || resp.Reason != "in_stock" || resp.Option == nil || resp.Option.Stock != 6 {
t.Fatalf("4 of 6 should be fine, got %+v", resp)
}
}
func TestConfirmPointsAtTheNextOutletWhenTheShelfIsEmpty(t *testing.T) {
repo := newLookupFixture()
repo.at = map[int]*repositories.StoreOptionRow{100: &repo.options[0]} // Peelamedu, stock 0
svc := NewScanService(repo, nil)
resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{
Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 100, Quantity: 1,
Latitude: "11.035", Longitude: "77.035",
})
if err != nil {
t.Fatal(err)
}
if resp.Ok || resp.Reason != "out_of_stock" {
t.Fatalf("expected out_of_stock, got %+v", resp)
}
if resp.Alternative == nil || resp.Alternative.Locationid != 20 {
t.Fatalf("Hopes is the nearest outlet with the same 100 g product, got %+v", resp.Alternative)
}
if len(resp.Alternative.Options) != 1 || resp.Alternative.Options[0].Productid != 200 {
t.Errorf("the alternative carries the same product, not its other sizes: %+v", resp.Alternative.Options)
}
if !resp.Alternative.Recommended {
t.Error("the alternative is the recommendation")
}
// Asking for more than anyone has: no alternative, honest reason.
resp, _ = svc.Confirm(context.Background(), models.ScanConfirmRequest{
Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 100, Quantity: 50,
})
if resp.Alternative != nil {
t.Errorf("nobody has 50; got alternative %+v", resp.Alternative)
}
}
func TestConfirmReportsInsufficientRatherThanOut(t *testing.T) {
repo := newLookupFixture()
repo.at = map[int]*repositories.StoreOptionRow{300: &repo.options[4]} // Gandhipuram, stock 2
svc := NewScanService(repo, nil)
resp, err := svc.Confirm(context.Background(), models.ScanConfirmRequest{
Customerid: 5, Tenantid: 3, Locationid: 30, Productid: 300, Quantity: 5,
})
if err != nil {
t.Fatal(err)
}
if resp.Ok || resp.Reason != "insufficient_stock" {
t.Fatalf("2 in stock, 5 asked: want insufficient_stock, got %+v", resp)
}
if resp.Alternative == nil || resp.Alternative.Locationid != 20 {
t.Errorf("Hopes has 6 of the same product, got %+v", resp.Alternative)
}
}
func TestConfirmRefusesAnUnregisteredStoreAndAnUnsoldProduct(t *testing.T) {
repo := newLookupFixture()
repo.at = map[int]*repositories.StoreOptionRow{}
svc := NewScanService(repo, nil)
resp, _ := svc.Confirm(context.Background(), models.ScanConfirmRequest{Customerid: 5, Tenantid: 9, Locationid: 90, Productid: 1})
if resp.Ok || resp.Reason != "store_not_registered" {
t.Errorf("got %+v", resp)
}
resp, _ = svc.Confirm(context.Background(), models.ScanConfirmRequest{Customerid: 5, Tenantid: 1, Locationid: 10, Productid: 424242})
if resp.Ok || resp.Reason != "not_sold_here" {
t.Errorf("got %+v", resp)
}
}
func TestStoresAreNearestFirstWithUnknownLast(t *testing.T) {
repo := newLookupFixture()
svc := NewScanService(repo, nil)
stores, err := svc.Stores(context.Background(), 5, "11.035", "77.035")
if err != nil {
t.Fatal(err)
}
if len(stores) != 3 || stores[0].Locationid != 10 || stores[1].Locationid != 20 || stores[2].Locationid != 30 {
t.Fatalf("want [10 20 30], got %+v", stores)
}
// No fix from the phone, saved address used instead.
repo.homeLat, repo.homeLng, repo.homeOK = 11.012, 77.001, true
stores, _ = svc.Stores(context.Background(), 5, "", "")
if stores[0].Locationid != 20 {
t.Errorf("from the saved address Hopes is nearest, got %+v", stores[0])
}
}
func TestCatalogueFamilyGroupsByVariantKeyThenName(t *testing.T) {
hits := []scoredHit{
{CatalogueHit: milkBikis, score: 0.95},
{CatalogueHit: goodDay, score: 0.6},
{CatalogueHit: milkBikis200, score: 0.88},
{CatalogueHit: repositories.CatalogueHit{Brand: "parle", ProductName: "Milk Bikis", VariantKey: "milk_bikis"}, score: 0.5},
}
family := catalogueFamily(hits)
if len(family) != 2 || family[1].ID != 8 {
t.Fatalf("family should be the two britannia sizes, got %+v", family)
}
// No variant keys: fall back to the name.
a := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 1, ProductName: "Honey"}}
b := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 2, ProductName: "honey "}}
c := scoredHit{CatalogueHit: repositories.CatalogueHit{Brand: "b", ID: 3, ProductName: "Honey Lite"}}
if family := catalogueFamily([]scoredHit{a, b, c}); len(family) != 2 {
t.Errorf("name match should join 1 and 2 only, got %+v", family)
}
}
// A vector neighbour that is merely not-quite-unrelated used to clear the old
// 0.30 floor: in production "Paracetamol" came back as "Paneer Makhni 500ml"
// on a 0.304 similarity. Correct labels land near 0.92, so nothing this weak
// is a match.
func TestLookupRefusesANearMissAboveTheOldFloor(t *testing.T) {
repo := newLookupFixture()
repo.vector = []repositories.CatalogueHit{{Brand: "amul", ID: 4, ProductName: "Paneer Makhni 500ml", Distance: 0.696}} // score 0.304
repo.text = nil
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "Paracetamol"})
if err != nil {
t.Fatal(err)
}
if resp.Match != nil {
t.Fatalf("0.304 is a near-miss, not a match; got %+v", resp.Match)
}
if resp.Available || len(resp.Stores) != 0 {
t.Fatalf("nothing should be offered without a match; got %+v", resp)
}
}
// Confirm answers about the store the customer tapped, so that store carries a
// distance on every outcome — not only on the out-of-stock path that ranks
// alternatives. Absent any position it stays -1, the documented "unknown".
func TestConfirmReportsDistanceToTheChosenStore(t *testing.T) {
repo := newLookupFixture()
repo.at = map[int]*repositories.StoreOptionRow{200: &repo.options[1]}
svc := NewScanService(repo, nil)
req := models.ScanConfirmRequest{Customerid: 5, Tenantid: 2, Locationid: 20, Productid: 200, Quantity: 4}
withPos := req
withPos.Latitude, withPos.Longitude = "11.035", "77.035"
resp, err := svc.Confirm(context.Background(), withPos)
if err != nil {
t.Fatal(err)
}
if !resp.Ok || resp.Store == nil {
t.Fatalf("expected the in-stock answer, got %+v", resp)
}
if resp.Store.DistanceKm <= 0 {
t.Fatalf("the phone sent a fix, so the tapped store has a distance; got %v", resp.Store.DistanceKm)
}
// No fix from the phone, but a saved address on file.
repo.homeLat, repo.homeLng, repo.homeOK = 11.035, 77.035, true
resp, err = svc.Confirm(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if resp.Store == nil || resp.Store.DistanceKm != -1 {
t.Fatalf("in stock is answered without reaching for the saved address; got %v", resp.Store)
}
// Neither: unknown, and the app sorts it last.
repo.homeOK = false
resp, err = svc.Confirm(context.Background(), req)
if err != nil {
t.Fatal(err)
}
if resp.Store == nil || resp.Store.DistanceKm != -1 {
t.Fatalf("no position at all is -1; got %v", resp.Store)
}
}
var parleG = repositories.CatalogueHit{Brand: "parle", ID: 1, ProductName: "Parle-G Original Glucose Biscuits 250g", Title: "Parle-G", VariantKey: "parle_g", ImageID: "parle_parle_g_250g", Distance: 0.20}
var monaco = repositories.CatalogueHit{Brand: "parle", ID: 2, ProductName: "Parle Monaco Classic Regular 200g", Title: "Monaco", VariantKey: "monaco", ImageID: "parle_monaco_200g", Distance: 0.20}
// Lens reads "Parle-G" off the packet and the customer types "Parle G". Both
// spellings, and the run-together one, have to reach the biscuit — not the
// salted cracker that merely shares a brand. In production "Parle G" returned
// "Parle Monaco Classic Regular 200g" at a confident 0.9.
func TestLookupMatchesAHyphenatedNameHoweverItIsWritten(t *testing.T) {
for _, label := range []string{"Parle G", "Parle-G", "ParleG", "parle g"} {
repo := newLookupFixture()
repo.vector = []repositories.CatalogueHit{monaco, parleG} // model puts the cracker first
repo.text = []repositories.CatalogueHit{monaco, parleG}
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: label})
if err != nil {
t.Fatal(err)
}
if resp.Match == nil {
t.Fatalf("%q: a stocked product went unrecognised", label)
}
if resp.Match.Catalogueid != parleG.ID {
t.Fatalf("%q: matched %q (%.3f), want Parle-G", label, resp.Match.ProductName, resp.Match.Score)
}
}
}
// Equal blended scores used to be settled by product name, which let ASCII
// decide relevance: a space sorts before a hyphen, so "Parle Monaco …" beat
// "Parle-G …". The model's own similarity settles it instead.
func TestSortHitsBreaksTiesOnSimilarityNotPunctuation(t *testing.T) {
near := parleG
near.Distance = 0.10 // the model is surer about this one
far := monaco
far.Distance = 0.40
hits := []scoredHit{{CatalogueHit: far, score: 0.9}, {CatalogueHit: near, score: 0.9}}
sortHits(hits)
if hits[0].ID != near.ID {
t.Fatalf("the nearer vector should win a tie, got %q", hits[0].ProductName)
}
// A row the model never scored (-1, text-only) ranks behind one it did.
textOnly := parleG
textOnly.Distance = -1
hits = []scoredHit{{CatalogueHit: textOnly, score: 0.9}, {CatalogueHit: far, score: 0.9}}
sortHits(hits)
if hits[0].ID != far.ID {
t.Fatalf("a scored row outranks an unscored one, got %q", hits[0].ProductName)
}
}