main had moved on with retrieval work validated against real queries — minTokenHits (the word match needs two thirds of the label, not all of it), separator folding so "Parle G"/"Parle-G"/"ParleG" all reach Parle-G, the floor at 0.50 after "Paracetamol" came back as "Paneer Makhni 500ml" at 0.304, and ties broken on cosine distance instead of name. All of that is kept exactly as it was. The conflict was in textScore: this branch replaced the substring rule with a coverage formula to stop a bare brand name resolving to one arbitrary product. That is the wrong half to change. The substring rule scores every product of a brand 0.95 IDENTICALLY, and that tie is not the bug — it is the signal. isAmbiguous reads it, so the branch's coverage rewrite is dropped and the ambiguity layer alone does the work: "britannia" → all 258 rows tie at 0.95 → ambiguous: true + candidates "Parle G" → folding and the single-character token still land it a real name → runner-up far behind → match, unchanged Dropped with it: scanSpecificEnough, the per-hit text score, and the proportional confirmation bonus — the flat +0.10 is back. Simpler, and it leaves main's tuning untouched. TestTextScoreRewardsSpecificityNotJustOverlap tested the removed formula and is replaced by TestABrandNameScoresItsProductsIdentically, which guards the tie itself: a formula that broke it on name length or word count would bring the bug back. Docs carry both rationales, and now say plainly that confidence stays high on the ambiguous path — gate on `ambiguous`, never on `confidence`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
785 lines
31 KiB
Go
785 lines
31 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"nearle/models"
|
|
"nearle/repositories"
|
|
"nearle/utils"
|
|
)
|
|
|
|
/*
|
|
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
|
|
|
|
ref []repositories.CatalogueHit
|
|
refErr error
|
|
|
|
askedKeys []repositories.CatalogueKey
|
|
askedNames []string
|
|
askedLocs []int
|
|
askedRef string
|
|
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) CatalogueRef(_ context.Context, brand string, id int64) ([]repositories.CatalogueHit, error) {
|
|
f.askedRef = fmt.Sprintf("%s#%d", brand, id)
|
|
return f.ref, f.refErr
|
|
}
|
|
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, hits[0])
|
|
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}, a); len(family) != 2 {
|
|
t.Errorf("name match should join 1 and 2 only, got %+v", family)
|
|
}
|
|
}
|
|
|
|
/*
|
|
Ambiguity.
|
|
|
|
Lens hands back whatever was most legible on the packet, and on a packet that
|
|
is very often the brand wordmark alone. "britannia" fits 258 catalogue rows
|
|
equally well, so there is no best one. textScore gives all of them 0.95 —
|
|
correctly, the label IS in every one of those names — and with nothing to
|
|
read that tie, the sort order picked a winner and the customer was shown one
|
|
arbitrary biscuit with "confidence": 0.95 and a price. These tests are the
|
|
contract that it asks instead.
|
|
*/
|
|
|
|
// Three different Britannia products, of which the customer's stores stock
|
|
// one. Text-only: no embedder, which is also how production runs until the
|
|
// model is configured.
|
|
func newBrandLabelFixture() *fakeScanRepo {
|
|
cashew := repositories.CatalogueHit{Brand: "britannia", ID: 21, ProductName: "Britannia Good Day Cashew Cookies", VariantKey: "good_day_cashew", ImageID: "britannia_good_day_cashew"}
|
|
butter := repositories.CatalogueHit{Brand: "britannia", ID: 22, ProductName: "Britannia Good Day Butter Cookies", VariantKey: "good_day_butter", ImageID: "britannia_good_day_butter"}
|
|
marie := repositories.CatalogueHit{Brand: "britannia", ID: 23, ProductName: "Britannia Marie Gold", VariantKey: "marie_gold", ImageID: "britannia_marie_gold"}
|
|
|
|
return &fakeScanRepo{
|
|
exists: true,
|
|
stores: fixtureStores(),
|
|
text: []repositories.CatalogueHit{cashew, butter, marie},
|
|
options: []repositories.StoreOptionRow{
|
|
{Tenantid: 2, Locationid: 20, Productid: 220, Productname: "Britannia Marie Gold",
|
|
Productbrand: "britannia", Catalogueid: 23, Imageid: "britannia_marie_gold", Price: 30, Stock: 4},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestABareBrandNameAsksInsteadOfGuessing(t *testing.T) {
|
|
repo := newBrandLabelFixture()
|
|
svc := NewScanService(repo, nil)
|
|
|
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
|
Customerid: 5, Label: "britannia", Latitude: "11.035", Longitude: "77.035",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if !resp.Ambiguous {
|
|
t.Fatalf("a bare brand name must not resolve to one product, got match %+v", resp.Match)
|
|
}
|
|
if resp.Match != nil {
|
|
t.Errorf("Match must be nil while ambiguous, got %+v", resp.Match)
|
|
}
|
|
if len(resp.Stores) != 0 {
|
|
t.Errorf("no store or price may be quoted for a product the customer has not chosen, got %d offers", len(resp.Stores))
|
|
}
|
|
if len(resp.Variants) != 0 {
|
|
t.Errorf("pack sizes belong to a chosen product, got %+v", resp.Variants)
|
|
}
|
|
if len(resp.Candidates) != 3 {
|
|
t.Fatalf("want the three distinct Britannia products, got %d: %+v", len(resp.Candidates), resp.Candidates)
|
|
}
|
|
|
|
// The one the customer can actually buy is offered first.
|
|
if !resp.Candidates[0].Available || resp.Candidates[0].Catalogueid != 23 {
|
|
t.Errorf("the stocked product should lead the list, got %+v", resp.Candidates[0])
|
|
}
|
|
for _, c := range resp.Candidates[1:] {
|
|
if c.Available {
|
|
t.Errorf("only Marie Gold is stocked, but %s reports available", c.ProductName)
|
|
}
|
|
}
|
|
// Note what confidence does NOT say here. The label appears verbatim in
|
|
// all three names, so relevance is high — and the answer is still a
|
|
// question. An app that gated on `confidence` instead of `ambiguous`
|
|
// would show a price for the wrong biscuit, which is the whole bug.
|
|
if resp.Confidence < 0.9 {
|
|
t.Errorf("a verbatim brand match scores high; %v suggests the scoring changed", resp.Confidence)
|
|
}
|
|
if !strings.Contains(resp.Message, "Which one") {
|
|
t.Errorf("the message should ask, got %q", resp.Message)
|
|
}
|
|
}
|
|
|
|
func TestAnAmbiguousLabelWithNoStockStillLists(t *testing.T) {
|
|
repo := newBrandLabelFixture()
|
|
repo.options = nil
|
|
svc := NewScanService(repo, nil)
|
|
|
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{Customerid: 5, Label: "britannia"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !resp.Ambiguous || len(resp.Candidates) != 3 {
|
|
t.Fatalf("want three candidates, got ambiguous=%v %d", resp.Ambiguous, len(resp.Candidates))
|
|
}
|
|
for _, c := range resp.Candidates {
|
|
if c.Available {
|
|
t.Errorf("%s cannot be available with no stock anywhere", c.ProductName)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The other half of the contract: a label that does name a product must not
|
|
// start asking questions.
|
|
func TestASpecificLabelStillWinsOutright(t *testing.T) {
|
|
repo := newBrandLabelFixture()
|
|
svc := NewScanService(repo, nil)
|
|
|
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
|
Customerid: 5, Label: "good day cashew",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Ambiguous {
|
|
t.Fatalf("a label naming one product should resolve, got candidates %+v", resp.Candidates)
|
|
}
|
|
if resp.Match == nil || resp.Match.Catalogueid != 21 {
|
|
t.Fatalf("want the cashew cookies, got %+v", resp.Match)
|
|
}
|
|
if len(resp.Candidates) != 0 {
|
|
t.Errorf("candidates belong to an ambiguous answer, got %+v", resp.Candidates)
|
|
}
|
|
}
|
|
|
|
// The property the ambiguity check rests on: a label that is a substring of
|
|
// several names scores them EQUALLY. Nothing downstream can tell "did you
|
|
// mean?" from "found it" if a formula breaks that tie on name length, word
|
|
// count or anything else incidental — which is how one arbitrary Britannia
|
|
// biscuit used to come back with a price on it.
|
|
func TestABrandNameScoresItsProductsIdentically(t *testing.T) {
|
|
cashew := repositories.CatalogueHit{ProductName: "Britannia Good Day Cashew Cookies 200g"}
|
|
butter := repositories.CatalogueHit{ProductName: "Britannia Good Day Butter Cookies 100g"}
|
|
// Deliberately a much shorter name: length must not become a tie-breaker.
|
|
marie := repositories.CatalogueHit{ProductName: "Britannia Marie Gold"}
|
|
|
|
tokens := utils.SearchTokens("britannia")
|
|
a, b, c := textScore(cashew, "britannia", tokens), textScore(butter, "britannia", tokens), textScore(marie, "britannia", tokens)
|
|
if a != b || b != c {
|
|
t.Fatalf("a brand must score its products equally, got %.3f / %.3f / %.3f", a, b, c)
|
|
}
|
|
if a == 0 {
|
|
t.Fatal("the brand name is in every one of those names; scoring it 0 would hide them all")
|
|
}
|
|
|
|
// And a label that does name a product must NOT tie with its siblings,
|
|
// or everything would be a question.
|
|
specific := utils.SearchTokens("good day cashew")
|
|
if textScore(cashew, "good day cashew", specific) <= textScore(butter, "good day cashew", specific) {
|
|
t.Error("a label naming one product must outscore its siblings")
|
|
}
|
|
|
|
if none := textScore(cashew, "dabur honey", utils.SearchTokens("dabur honey")); none != 0 {
|
|
t.Errorf("nothing in common should score 0, got %.3f", none)
|
|
}
|
|
}
|
|
|
|
func TestDistinctProductsCollapsesPackSizes(t *testing.T) {
|
|
hits := []scoredHit{
|
|
{CatalogueHit: milkBikis, score: 1},
|
|
{CatalogueHit: milkBikis200, score: 0.9},
|
|
{CatalogueHit: goodDay, score: 0.5},
|
|
}
|
|
distinct := distinctProducts(hits)
|
|
if len(distinct) != 2 || distinct[0].ID != 7 || distinct[1].ID != 9 {
|
|
t.Fatalf("two sizes of one product are one choice, got %+v", distinct)
|
|
}
|
|
}
|
|
|
|
// Picking a candidate: the app sends the key instead of a description, and
|
|
// nothing is recognised at all.
|
|
func TestNamingTheProductSkipsRecognition(t *testing.T) {
|
|
repo := newLookupFixture()
|
|
repo.ref = []repositories.CatalogueHit{milkBikis, milkBikis200}
|
|
// If recognition ran, these would decide the answer instead.
|
|
repo.text = []repositories.CatalogueHit{goodDay}
|
|
repo.vector = []repositories.CatalogueHit{goodDay}
|
|
svc := NewScanService(repo, fakeEmbedder{vec: []float32{0.1}})
|
|
|
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
|
Customerid: 5, Brand: "britannia", Catalogueid: 7,
|
|
Latitude: "11.035", Longitude: "77.035",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if repo.askedRef != "britannia#7" {
|
|
t.Fatalf("the catalogue should have been asked for that exact product, got %q", repo.askedRef)
|
|
}
|
|
if resp.Match == nil || resp.Match.Catalogueid != 7 || resp.Match.Method != "direct" {
|
|
t.Fatalf("want a direct match on 7, got %+v", resp.Match)
|
|
}
|
|
if resp.Ambiguous || resp.Confidence != 1 {
|
|
t.Errorf("a named product is not a guess: ambiguous=%v confidence=%v", resp.Ambiguous, resp.Confidence)
|
|
}
|
|
if len(resp.Variants) != 2 {
|
|
t.Errorf("its pack sizes should come with it, got %+v", resp.Variants)
|
|
}
|
|
if !resp.Available || resp.RecommendedLocationid != 20 {
|
|
t.Errorf("stores are resolved exactly as for a recognised product, got %+v", resp.Stores)
|
|
}
|
|
}
|
|
|
|
func TestAMissingCatalogueRefIsNotAMatch(t *testing.T) {
|
|
repo := newLookupFixture()
|
|
repo.ref = nil
|
|
svc := NewScanService(repo, nil)
|
|
|
|
resp, err := svc.Lookup(context.Background(), models.ScanLookupRequest{
|
|
Customerid: 5, Brand: "britannia", Catalogueid: 999,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if resp.Match != nil || resp.Ambiguous || len(resp.Stores) != 0 {
|
|
t.Fatalf("a product that is gone is not a match, got %+v", resp)
|
|
}
|
|
if !strings.Contains(resp.Message, "no longer") {
|
|
t.Errorf("the message should say the product is gone, got %q", resp.Message)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|