556 lines
22 KiB
Go
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)
|
|
}
|
|
}
|