All checks were successful
Deploy production / deploy (push) Successful in 1m13s
- search.ts: score on the core name (brand/size stripped): exact or query+base suffix (kipdij+filet), head-final compounds, prefix, inside; penalties per extra word and per flavour/processing word (search-words.ts, editable); bonuses for store brands, the query's dominant category, store count, favourites; trigram tiebreak - groceries.synonyms table (seeded: kipdij/kippendij, pasta → spaghetti, …) expands queries - list rows show all pack sizes of a group; group picker uses the same ranking - scripts/groceries-search-eval.ts prints top results + latency; preflight now 8 tables
140 lines
3.5 KiB
TypeScript
140 lines
3.5 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
bestPerStore,
|
|
cheapest,
|
|
formatAmount,
|
|
formatUnitPrice,
|
|
packLabel,
|
|
packSizes,
|
|
parseEuros,
|
|
sortByPrice,
|
|
toOffers,
|
|
type PricedItem
|
|
} from './groceries';
|
|
|
|
const it_ = (
|
|
id: number,
|
|
storeId: number,
|
|
price: number,
|
|
unit: number,
|
|
promo: number | null = null
|
|
): PricedItem => ({
|
|
id,
|
|
storeId,
|
|
priceCents: price,
|
|
promoPriceCents: promo,
|
|
unitPriceCents: unit,
|
|
promoUnitPriceCents: promo == null ? null : Math.round((unit * promo) / price),
|
|
available: true
|
|
});
|
|
|
|
describe('grocery price helpers', () => {
|
|
const items = [
|
|
it_(1, 1, 119, 119),
|
|
it_(2, 1, 165, 110),
|
|
it_(3, 2, 115, 115),
|
|
it_(4, 2, 200, 200, 100)
|
|
];
|
|
it('finds the cheapest by unit price, promos included', () => {
|
|
const c = cheapest(items, 'unit');
|
|
expect(c.overall).toBe(4);
|
|
expect([...c.perStore].sort()).toEqual([2, 4]);
|
|
});
|
|
it('finds the cheapest by pack price', () => {
|
|
expect(cheapest(items, 'pack').overall).toBe(4);
|
|
expect(cheapest(items.slice(0, 3), 'pack').overall).toBe(3);
|
|
});
|
|
it('sorts by unit price', () => {
|
|
expect(sortByPrice(items, 'unit').map((i) => i.id)).toEqual([4, 2, 3, 1]);
|
|
});
|
|
it('formats', () => {
|
|
expect(formatUnitPrice(250, 'kg')).toMatch(/2,50\/kg$/);
|
|
expect(formatAmount(1.98, 'l')).toBe('1,98 L');
|
|
expect(formatUnitPrice(null, 'kg')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('parseEuros', () => {
|
|
it('reads Dutch and English prices', () => {
|
|
expect(parseEuros('1,29')).toBe(129);
|
|
expect(parseEuros('€ 2.5')).toBe(250);
|
|
expect(parseEuros('3')).toBe(300);
|
|
});
|
|
it('rejects nonsense', () => {
|
|
expect(parseEuros('')).toBeNull();
|
|
expect(parseEuros('0')).toBeNull();
|
|
expect(parseEuros('1,299')).toBeNull();
|
|
expect(parseEuros('abc')).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('bestPerStore', () => {
|
|
const item = (
|
|
id: number,
|
|
storeId: number,
|
|
price: number,
|
|
unit: number | null,
|
|
promo?: number
|
|
) => ({
|
|
id,
|
|
storeId,
|
|
name: `p${id}`,
|
|
sizeText: null,
|
|
unit: 'kg',
|
|
unitAmount: 1,
|
|
promoLabel: promo ? '1+1' : null,
|
|
url: null,
|
|
available: true,
|
|
priceCents: price,
|
|
promoPriceCents: promo ?? null,
|
|
unitPriceCents: unit,
|
|
promoUnitPriceCents: promo ?? null
|
|
});
|
|
it('keeps the cheapest offer per store, cheapest store first, with the spread', () => {
|
|
const offers = toOffers(
|
|
[item(1, 1, 300, 300), item(2, 1, 250, 250), item(3, 2, 400, 400, 200), item(4, 3, 500, 500)],
|
|
[
|
|
{
|
|
id: 9,
|
|
itemId: null,
|
|
storeId: 4,
|
|
priceCents: 180,
|
|
promo: false,
|
|
sizeText: '1 kg',
|
|
unit: 'kg',
|
|
unitPriceCents: 180,
|
|
observedOn: '2026-10-20',
|
|
photoUrl: null,
|
|
note: null
|
|
}
|
|
]
|
|
);
|
|
const r = bestPerStore(offers, 'unit');
|
|
expect(r.stores.map((o) => o.key)).toEqual(['m9', 'i3', 'i2', 'i4']);
|
|
expect(r.best?.manual?.id).toBe(9);
|
|
expect(r.stores[1].regularCents).toBe(400);
|
|
expect(r.spread).toEqual({ min: 180, max: 500, pct: 178 });
|
|
});
|
|
it('has no spread for one store', () => {
|
|
expect(bestPerStore(toOffers([item(1, 1, 100, 100)], []), 'pack').spread).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('packSizes', () => {
|
|
it('lists distinct pack sizes smallest first with friendly units', () => {
|
|
expect(
|
|
packSizes([
|
|
{ unitAmount: 0.8, unit: 'kg' },
|
|
{ unitAmount: 0.22, unit: 'kg' },
|
|
{ unitAmount: 0.45, unit: 'kg' },
|
|
{ unitAmount: 0.22, unit: 'kg' },
|
|
{ unitAmount: 1.5, unit: 'kg', available: false },
|
|
{ unitAmount: null, unit: null }
|
|
])
|
|
).toEqual(['220 g', '450 g', '800 g']);
|
|
expect(packLabel(1.5, 'l')).toBe('1,5 L');
|
|
expect(packLabel(0.33, 'l')).toBe('330 ml');
|
|
expect(packLabel(6, 'piece')).toBe('6 st');
|
|
});
|
|
});
|