Files
backend_fiesta/services/scan_test.go
Suriyakumarvijayanayagam 01bc89ab77 Ask instead of guessing when a label fits several products
`"britannia"` is a substring of all 258 Britannia product names, and
textScore returned 0.95 for any product whose name contained the label.
So every one of them tied, the tie broke alphabetically, and the customer
was shown one arbitrary biscuit with "confidence": 0.95 and a price. Lens
hands back a bare wordmark often — it is usually the biggest thing printed
on a packet — so this was the common case, not an edge one. Found via the
example request in the mobile team's own proposal.

Scoring now asks both questions. A hit carries `score` (ranks) and `text`
(how specifically the label names THIS product: the harmonic mean of how
much of the label the product explains and how much of the product's name
the label explains, pack sizes dropped from both sides). A brand name
scores its products ~0.33 equally instead of 0.95 arbitrarily. The
"vector and text agree" bonus is now proportional to the text score, so a
weak match can no longer inflate a whole brand.

isAmbiguous reads that: the leader is a guess if anything is level with it
(margin) or if the label names no one product (specificity), and then the
response carries `ambiguous: true` with `candidates` — distinct products,
not pack sizes, at most ten, each marked with whether one of the
customer's stores has it in stock, available ones first. `match` is nil
and `stores` empty on that path: no price for a product nobody chose.
Erring towards asking is deliberate — a tap versus the wrong biscuit.

To act on a pick, /lookup now accepts `brand` + `catalogueid` instead of a
label and skips recognition entirely (also serves deep links and re-order).
New: ScanRepository.CatalogueRef, resolving via the brand tables discovered
from information_schema, never a name built from the request.

Also: scratch/cataloguedims now reports every vector column, not just
`embedding` — which is how we learned the catalogue also carries
img_vector(1024), filled on 1885 of 2124 rows. SCAN_TO_ORDER.md records
why that column stays unread for now and what would change it, alongside
why the app is not asked to compute vectors on the phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 10:56:02 +05:30

660 lines
26 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 — and the old scoring said otherwise:
every product whose name contained the label scored 0.95, the tie broke
alphabetically, 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)
}
}
if resp.Confidence >= 0.55 {
t.Errorf("confidence should record how weak the identification was, got %v", 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)
}
}
func TestTextScoreRewardsSpecificityNotJustOverlap(t *testing.T) {
cashew := repositories.CatalogueHit{ProductName: "Britannia Good Day Cashew Cookies 200g"}
butter := repositories.CatalogueHit{ProductName: "Britannia Good Day Butter Cookies 100g"}
brandOnly := textScore(cashew, "britannia", utils.SearchTokens("britannia"))
if brandOnly > 0.45 {
t.Errorf("a brand name explains one word of five and must not score as an identification, got %.3f", brandOnly)
}
if brandOnly != textScore(butter, "britannia", utils.SearchTokens("britannia")) {
t.Error("a brand must score its products equally — that tie is what makes the label read as ambiguous")
}
full := textScore(cashew, "Britannia Good Day Cashew Cookies", utils.SearchTokens("Britannia Good Day Cashew Cookies"))
if full < 0.95 {
t.Errorf("the product's own name should be near-certain, got %.3f", full)
}
// A pack size on either side is not a word the label failed to explain.
sized := textScore(repositories.CatalogueHit{ProductName: "Milk Bikis 100g"}, "Milk Bikis", utils.SearchTokens("Milk Bikis"))
if sized < 0.95 {
t.Errorf("pack sizes must not count against the match, got %.3f", sized)
}
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)
}
}