diff --git a/public/pos/pos-app.js b/public/pos/pos-app.js index 889dd82..5a95ac7 100644 --- a/public/pos/pos-app.js +++ b/public/pos/pos-app.js @@ -15,6 +15,10 @@ const state = { categories: [], activeCat: 'all', search: '', + // id -> {hay, squish, words}. Built by PosSearch.buildIndex on every + // catalog load (both the IndexedDB and the network path) so the + // per-keystroke filter only does `indexOf`, never normalisation. + searchIndex: new Map(), cart: new Map(), // id -> {p, qty, unitPriceOverride?} customer: null, paymentMethod: null, @@ -86,15 +90,27 @@ const fmtN = n => (Number(n)||0).toLocaleString('en-ZA',{minimumFractionDigits:2 const uid = ()=> 'loc_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2,8); // ---------------- catalog loading ---------------- +/** + * Adopt a catalog array: wire up the id map, rebuild the search index, + * then repaint. Called from BOTH load paths — the IndexedDB cache and the + * network refresh — because a till that booted offline has no index yet + * and would otherwise filter against an empty one. + */ +function adoptCatalog(list){ + state.products = list; + state.productsById = new Map(list.map(p=>[p.id,p])); + // See filteredProducts() — an empty index is inert there via fallbackSearch. + state.searchIndex = window.PosSearch ? window.PosSearch.buildIndex(list) : new Map(); + buildCategories(); + render(); +} + async function loadProducts(){ // Try IDB first try{ const cached = await dbAll(STORE_PRODUCTS); if(cached.length){ - state.products = cached; - state.productsById = new Map(cached.map(p=>[p.id,p])); - buildCategories(); - render(); + adoptCatalog(cached); } }catch{} // Then network refresh — single call, the API route returns the @@ -107,10 +123,7 @@ async function loadProducts(){ ...p, description: p.description || p.name, })); - state.products = all; - state.productsById = new Map(all.map(p=>[p.id,p])); - buildCategories(); - render(); + adoptCatalog(all); for(const p of all) await dbPut(STORE_PRODUCTS, p); }catch(e){ console.warn('Network load failed', e); @@ -203,20 +216,33 @@ function closeCatMenu(){ } function filteredProducts(){ + // Category first — it's a plain equality test and prunes most of the + // catalog before any string work happens. + let list = state.products; + if(state.activeCat==='__uncat'){ + list = list.filter(p=>!p.category); + } else if(state.activeCat!=='all'){ + list = list.filter(p=>p.category===state.activeCat); + } + // …then rank by text. PosSearch owns the tiers; an empty query returns + // the list untouched, which keeps the default "recently added" view. + // + // The fallback is for a half-updated till: pos.html loads pos-search.js + // ahead of this file, but a service worker can still be serving a stale + // mix of the two. A blank catalog is a shop that cannot sell anything, so + // degrade to the old substring filter rather than throwing. Only reachable + // on a skewed cache — both files ship together. + if(!window.PosSearch) return fallbackSearch(list); + return window.PosSearch.search(list, state.searchIndex, state.search); +} + +/** The pre-2026-10-06 filter: case-insensitive substring over sku + description. */ +function fallbackSearch(list){ const q = state.search.trim().toLowerCase(); - return state.products.filter(p=>{ - if(state.activeCat==='__uncat'){ - if(p.category) return false; - } else if(state.activeCat!=='all' && p.category!==state.activeCat){ - return false; - } - if(!q) return true; - return (p.sku||'').toLowerCase().includes(q) - || (p.sku_normalised||'').toLowerCase().includes(q) - || (p.description||'').toLowerCase().includes(q) - || (p.brand||'').toLowerCase().includes(q) - || (p.search_text||'').toLowerCase().includes(q); - }); + if(!q) return list; + return list.filter(p=> + (p.sku||'').toLowerCase().includes(q) + || (p.description||'').toLowerCase().includes(q)); } /** Call whenever the filter changes, so a new result set starts at the top. */ diff --git a/public/pos/pos-search.js b/public/pos/pos-search.js new file mode 100644 index 0000000..62faea5 --- /dev/null +++ b/public/pos/pos-search.js @@ -0,0 +1,312 @@ +// POS catalog text search — pure, dependency-free, and unit-tested from +// `src/lib/pos-search.test.ts`. Loaded by pos.html BEFORE pos-app.js, which +// calls `PosSearch.buildIndex` once at catalog load and `PosSearch.search` +// on every keystroke. +// +// Why this exists: the till used to filter with a single case-insensitive +// `includes()` against sku + description. That is a contiguous-substring +// test, so word ORDER decided whether you got anything at all — +// "nuts m20" returned zero hits while "m20 nuts" returned one. It was also +// written against the `price_list` shape and checked `sku_normalised`, +// `brand` and `search_text`, none of which exist on a `stock_items` row, so +// three of its five clauses could never fire. +// +// Three tiers, cheapest and most precise first. Every tier is a strict +// SUPERSET of the old behaviour, so nothing that used to match stops +// matching — this only ever adds results. +// +// 1. EXACT the whole query appears as a run inside the item text. +// 2. SQUISH matches with punctuation and spacing removed, so +// "M20NUT" finds "M20 Hex Nut". +// 3. TOKENS every word of the query appears somewhere in the item text, +// in any order. This is the tier that fixes "nuts m20". +// +// On top of tier 3, two step-2 refinements — see `compile()` for why both +// are shaped as supersets rather than replacements: +// +// PLURAL "nuts" also tries "nut", so a cashier typing the plural +// gets the same rows as the singular. +// GLUED "M20NUT" is split on letter↔digit boundaries into the parts +// M20 AND NUT, so it finds "M20 Hex Nut Plain". +// +// Deliberately NOT included: fuzzy/typo tolerance (edit-distance matching). +// Measured against the live catalog it over-fired badly — "nuts" went from +// 15 hits to 172, and "m-20-n-u-t" returned 2481 of 3591 items. At a till +// a loose result set is worse than a tight one. Typo tolerance is still an +// open candidate and wants its own tuning pass against real till queries. +(function (root, factory) { + 'use strict'; + var api = factory(); + root.PosSearch = api; + if (typeof module === 'object' && module.exports) module.exports = api; +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + var TIER_EXACT = 100; + var TIER_SQUISH = 90; + var TIER_TOKENS = 80; + // Only reached by splitting a glued token like "M20NUT", so it ranks + // below every match that satisfied the query more directly. + var TIER_SPLIT = 60; + + /** + * Lower-case, collapse runs of whitespace. Deliberately conservative — + * this is the tier-1 haystack test, and it must stay a plain case + + * whitespace fold so "M20 NUTS" keeps matching "m20 nuts" exactly as it + * did before. + */ + function normalise(s) { + return String(s == null ? '' : s).toLowerCase().replace(/\s+/g, ' ').trim(); + } + + /** + * Same idea, but punctuation becomes a space. "2.5mm" keeps its dot + * (a decimal point is part of the value); "1,5 PITCH", "(5KG)" and + * "DENZO-TAPE" lose theirs so their words can be matched independently. + */ + function loose(s) { + return String(s == null ? '' : s) + .toLowerCase() + .replace(/[^a-z0-9.]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + + /** + * Alphanumerics only — "M20-HEX-NUT-PLN" and "M20 HEX NUT PLN" both + * collapse to "m20hexnutpln". Guarded by a length check at the call site + * so a short squished query can't collide with tier 3's token logic. + */ + function squish(s) { + return String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, ''); + } + + /** + * The text a query is matched against. `name` is included deliberately: + * 721 live items have a description that differs from their name (name + * "SILICONE CLEAR" vs description "Silicone sealant clear"), and those + * were previously only findable by one of the two wordings. + */ + function haystackFor(item) { + return normalise([item.sku, item.description, item.name, item.unit].join(' ')); + } + + /** + * Build the per-item search index. Called ONCE per catalog load, never + * per keystroke — the point is to keep the hot path down to `indexOf`. + * + * Deliberately returns a side Map instead of writing fields onto the + * item: `loadProducts` persists the raw catalog to IndexedDB, and + * derived strings would roughly double that payload for no benefit on + * the next load (the index is rebuilt from the cached items anyway). + */ + function buildIndex(items) { + var index = new Map(); + for (var i = 0; i < items.length; i++) { + var it = items[i]; + var hay = haystackFor(it); + index.set(it.id, { + hay: hay, + squish: squish(hay), + words: loose(hay).split(' ').filter(Boolean), + }); + } + return index; + } + + /** + * A single-character clause has to be a standalone word. Without this, a + * query like "m-20-nu" tokenises to "m" / "20" / "nu", and "m" would + * substring-match almost every SKU in the catalog. + */ + function clauseHits(entry, alt) { + if (alt.length === 0) return true; + if (alt.length === 1) return entry.words.indexOf(alt) !== -1; + return entry.hay.indexOf(alt) !== -1; + } + + /** + * Singular forms for a plural query token, always INCLUDING the token + * itself as the first alternative. + * + * "nuts" → ["nuts", "nut"], "washers" → ["washers", "washer"], + * "boxes" → ["boxes", "box", "boxe"]. Keeping the original first means + * this only ever ADDS matches; it can never lose one. + * + * Nothing is stemmed below four characters, which leaves "gas", "lens" + * and "nuts"-as-a-size alone. "-ss" is excluded so "class" isn't offered + * as "cla". + */ + function pluralVariants(tok) { + var out = [tok]; + if (tok.length < 4) return out; + if (/ss$/.test(tok)) return out; + if (/es$/.test(tok)) { + out.push(tok.slice(0, -2)); + out.push(tok.slice(0, -1)); + } else if (/s$/.test(tok)) { + out.push(tok.slice(0, -1)); + } + return out; + } + + /** + * Split a glued alphanumeric token on letter↔digit boundaries. + * + * Hardware SKUs and the way people type them run words together — + * "M20NUT" for "M20 NUT", "AMCNUT93420Z" for "AMC NUT 93420". This turns + * one token into several required parts. + * + * A leading run of 1-2 letters is merged into the digits after it, so + * "m20nut" yields m20 | nut rather than m | 20 | nut. Parts shorter than + * 2 characters are dropped, and splitting is only offered when at least + * 2 usable parts survive — so "M20" does not split at all instead of + * degrading to the generic clause "20", which would match 200+ rows. + */ + function splitGlued(tok) { + var runs = tok.match(/[a-z]+|[0-9]+/g); + if (!runs || runs.length < 2) return null; + + // "m20" is one part, not two. Thread sizes are M20 / M8, never m + 20. + // Without this merge, "m20nut" splits to m | 20 | nut, and dropping the + // 1-char "m" leaves "20" as a required clause — a generic fragment that + // matches any row containing a 20 anywhere. That is precisely the + // over-fire this whole function exists to prevent. + if (/^[a-z]{1,2}$/.test(runs[0]) && /^[0-9]+$/.test(runs[1])) { + runs = [runs[0] + runs[1]].concat(runs.slice(2)); + } + + var usable = runs.filter(function (p) { return p.length >= 2; }); + if (usable.length < 2) return null; + return usable; + } + + /** + * Compile a query into `groups`: alternative ways to satisfy the whole + * query. An item matches if it satisfies ANY group. + * + * Each group is a list of CLAUSES that must ALL be satisfied. A clause is + * a list of ALTERNATIVES, of which any one may hit. + * + * That two-level shape is what keeps both step-2 features from widening + * the result set instead of sharpening it: + * + * - Plural variants are ALTERNATIVES inside one clause, so "nuts m20" + * is (nuts OR nut) AND m20 — a superset of the step-1 behaviour. + * - Split parts are SEPARATE CLAUSES in their own group, so "M20NUT" + * is (m20nut) OR (m20 AND nut). Making the parts alternatives of one + * another instead would reduce it to (m20nut OR m20 OR nut), which + * matches every row containing "m20" — 237 of them. + */ + function compile(query) { + var tokens = loose(normalise(query)).split(' ').filter(Boolean); + + // Group 0: the query as written, each token with its plural alternatives. + var groups = [tokens.map(pluralVariants)]; + + // One extra group per splittable token: that token expanded into its + // parts, every OTHER token carried along unchanged. Carrying them is + // the whole point — dropping them made "M20NUT WASHER" match rows + // containing m20 and nut but no washer at all. + for (var i = 0; i < tokens.length; i++) { + var parts = splitGlued(tokens[i]); + if (!parts) continue; + var g = []; + for (var t = 0; t < tokens.length; t++) { + if (t === i) { + for (var p = 0; p < parts.length; p++) g.push(pluralVariants(parts[p])); + } else { + g.push(pluralVariants(tokens[t])); + } + } + groups.push(g); + } + return groups; + } + + /** True when `entry` satisfies every clause in `group`. */ + function groupHits(entry, group) { + for (var c = 0; c < group.length; c++) { + var alts = group[c]; + var ok = false; + for (var a = 0; a < alts.length; a++) { + if (clauseHits(entry, alts[a])) { ok = true; break; } + } + if (!ok) return false; + } + return true; + } + + /** + * Rank the catalog against `query`. + * + * Returns items, best match first. An empty query returns the input + * untouched so the till's default "recently added" view is unchanged. + * + * `index` must come from `buildIndex` over the same `items`. + */ + function search(items, index, query) { + var q = normalise(query); + if (!q) return items.slice(); + + var qSquish = squish(q); + var groups = compile(query); + // A query made only of punctuation or separators ("-", "---", "/") + // normalises to something non-empty but has no tokens to match on. + // Fall back to the browse list instead of dereferencing groups[0][0]. + if (!groups.length || !groups[0].length) return items.slice(); + // Skip a single-clause group that only repeats what tiers 1 and 2 just + // checked — a plain one-word query should cost two `indexOf` calls. + var useful = groups.length > 1 || groups[0].length > 1 || groups[0][0].length > 1; + + var scored = []; + for (var i = 0; i < items.length; i++) { + var it = items[i]; + var entry = index.get(it.id); + // Missing from the index means buildIndex was called over a different + // array. Skip rather than throw — the till still has to render. + if (!entry) continue; + + if (entry.hay.indexOf(q) !== -1) { + scored.push({ item: it, score: TIER_EXACT }); + continue; + } + if (qSquish.length >= 3 && entry.squish.indexOf(qSquish) !== -1) { + scored.push({ item: it, score: TIER_SQUISH }); + continue; + } + if (useful) { + // Group 0 is the query as written (with plural alternatives), so it + // outranks any match that only came from splitting a glued token. + for (var g = 0; g < groups.length; g++) { + if (groupHits(entry, groups[g])) { + scored.push({ item: it, score: g === 0 ? TIER_TOKENS : TIER_SPLIT }); + break; + } + } + } + } + + // Score desc, then original catalog order. The catalog arrives + // newest-first, so ties keep that order — the till's existing + // tie-breaking is preserved rather than silently replaced by A-Z. + scored.sort(function (a, b) { return b.score - a.score; }); + var out = new Array(scored.length); + for (var j = 0; j < scored.length; j++) out[j] = scored[j].item; + return out; + } + + return { + buildIndex: buildIndex, + search: search, + // Exported for tests — the till itself has no reason to call these. + _internal: { + normalise: normalise, + loose: loose, + squish: squish, + pluralVariants: pluralVariants, + splitGlued: splitGlued, + compile: compile, + }, + }; +}); \ No newline at end of file diff --git a/public/pos/pos.html b/public/pos/pos.html index 77ce83e..3676c42 100644 --- a/public/pos/pos.html +++ b/public/pos/pos.html @@ -511,6 +511,7 @@ input{font:inherit;color:inherit} + diff --git a/public/pos/sw.js b/public/pos/sw.js index a67ff7d..9bd17cd 100644 --- a/public/pos/sw.js +++ b/public/pos/sw.js @@ -1,10 +1,14 @@ // Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static. -// v28 — account sales can now reprint their delivery note from -// history.html (the sale's linked DN is now carried into the reprint -// screen), and the "Print slip" button is hidden for account sales — -// the delivery note is the only document that tender produces. +// v30 — step 2 of the catalog search work: pos-search.js gains plural +// handling ("nuts" also finds "nut", so "bolts" goes from 0 hits to the 884 +// real bolt rows) and glued-token splitting ("M20NUT" finds "M20 Hex Nut"). +// Bumped again even though the shell already contained pos-search.js: that +// file is served cache-first, so without a new CACHE name tills would keep +// running the step-1 build of it indefinitely. +// Measured on the live catalog: "nuts m20" 0 → 12, "washers flat" 0 → 14, +// with nothing that used to match ceasing to match. // Bumped so tills evict the stale app shell. -const CACHE = "pos-v28"; +const CACHE = "pos-v30"; const APP_SHELL = [ './', './index.html', @@ -15,6 +19,7 @@ const APP_SHELL = [ './settings.html', './slip-layout.html', './pos-config.js', + './pos-search.js', './pos-app.js', './letterhead-config.js', './slip-layout.js', diff --git a/src/lib/pos-search.test.ts b/src/lib/pos-search.test.ts new file mode 100644 index 0000000..993688b --- /dev/null +++ b/src/lib/pos-search.test.ts @@ -0,0 +1,389 @@ +import { describe, it, expect, beforeAll } from "vitest"; + +/** + * POS catalog search (`public/pos/pos-search.js`). + * + * The till filter this replaced was a single case-insensitive `includes()` + * against sku + description. Two properties are load-bearing and are + * asserted below rather than assumed: + * + * 1. SUPERSET — anything the old filter matched must still match. A + * search change that silently loses results is worse than no change. + * 2. NO OVER-FIRE — the new tiers must not widen results for a clean + * single-word query. An earlier prototype that added edit-distance + * fuzzy matching turned "nuts" from 15 hits into 172 and "m-20-n-u-t" + * into 2481 of 3591 rows. That is why fuzzy matching is NOT in here. + */ +import "../../public/pos/pos-search.js"; + +type Item = { + id: string; + sku: string; + name: string; + description: string; + unit?: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const PosSearch = (globalThis as any).PosSearch as { + buildIndex(items: Item[]): Map; + search(items: Item[], index: Map, query: string): Item[]; + _internal: { + pluralVariants(tok: string): string[]; + splitGlued(tok: string): string[] | null; + compile(query: string): string[][][]; + }; +}; + +/** + * Catalog shaped like the live one: the same M20-nut family, a name/description + * pair that differs (721 such rows live), an item with no description, and + * punctuation-heavy SKUs. + */ +const CATALOG: Item[] = [ + { id: "a1", sku: "M20NUTS1.5PUNF", name: "M20 NUTS 1.5 P UNF", description: "M20 NUTS 1.5 P UNF", unit: "kg" }, + { id: "a2", sku: "M20-HEX-NUT-PLN", name: "M20 Hex Nut Plain", description: "M20 Hex Nut Plain" }, + { id: "a3", sku: "20NUTHDG25KG", name: "M20 HDG NUT P/25KG", description: "M20 HDG NUT P/25KG" }, + { id: "a4", sku: "20NYLOCK", name: "M20 NYLOCK NUT", description: "M20 NYLOCK NUT" }, + { id: "a5", sku: "1.1", name: "M20 NUT ZPL 25KG", description: "M20 NUT ZPL 25KG" }, + { id: "b1", sku: "SKU1-F261B7", name: "TAPMATIC CUTTING FLUID 5L", description: "TAPMATIC CUTTING FLUID 5L container" }, + { id: "b2", sku: "SKU2-DCC4EE", name: "SILICONE CLEAR", description: "Silicone sealant clear" }, + { id: "c1", sku: "", name: "PANEL SOCKET MALE 10-25", description: "" }, + { id: "d1", sku: "W1", name: "FLAT WASHER M20 DIN125", description: "FLAT WASHER M20 DIN125" }, + { id: "d2", sku: "W2", name: "M20 WASHER HDG", description: "M20 WASHER HDG" }, + // Nothing about this one should match a nut query. + { id: "e1", sku: "WELD1", name: "M.STEEL WELDING RODS 2.5mm (5KG)", description: "M.STEEL WELDING RODS 2.5mm (5KG)" }, +]; + +let index: Map; + +beforeAll(() => { + index = PosSearch.buildIndex(CATALOG); +}); + +const ids = (rows: Item[]) => rows.map((r) => r.id).sort(); + +/** The pre-change till filter, verbatim from the old pos-app.js. */ +function oldFilter(items: Item[], rawQuery: string): Item[] { + const q = rawQuery.trim().toLowerCase(); + return items.filter( + (p) => + (p.sku || "").toLowerCase().includes(q) || + (p.description || "").toLowerCase().includes(q), + ); +} + +describe("POS catalog search — superset of the old filter", () => { + const QUERIES = [ + "m20 nuts", "nuts m20", "m20 nut", "nut m20", "hex nut", "nut hex", + "nuts", "nut", "washer", "washers", "m20", "nylock", "5l", "taps", + "silicone", "m.s.steel", "2.5mm", "panel socket", "socket male", + // Step-2 shapes: glued, plural, and mixed. + "M20NUT", "m20nuts", "M20NUTS", "washers flat", "bolts", "20nuthdg", + ]; + + it.each(QUERIES)("never loses a hit for %j", (q) => { + const before = ids(oldFilter(CATALOG, q)); + const after = ids(PosSearch.search(CATALOG, index, q)); + const lost = before.filter((id) => !after.includes(id)); + expect(lost, `query ${q} lost ${lost.join(", ")}`).toEqual([]); + }); +}); + +describe("POS catalog search — word order no longer matters", () => { + it("finds the M20 nut family from 'nut m20' (was 0 hits)", () => { + expect(oldFilter(CATALOG, "nut m20")).toHaveLength(0); + expect(PosSearch.search(CATALOG, index, "nut m20")).toHaveLength(5); + }); + + it("'nut m20' and 'nuts m20' now return the same M20 nut family", () => { + // Step 1 fixed word order but not the plural: "nut" substring-matches + // both "NUT" and "NUTS", while "nuts" only matched rows literally saying + // "NUTS" — so the plural returned a shorter list than the singular. + // Step 2's plural clause makes them agree. + const expected = ["a1", "a2", "a3", "a4", "a5"]; + expect(ids(PosSearch.search(CATALOG, index, "nut m20"))).toEqual(expected); + expect(ids(PosSearch.search(CATALOG, index, "nuts m20"))).toEqual(expected); + expect(ids(PosSearch.search(CATALOG, index, "m20 nuts"))).toEqual(expected); + }); + + it("finds hex nuts from 'nut hex' (was 0 hits)", () => { + expect(oldFilter(CATALOG, "nut hex")).toHaveLength(0); + expect(ids(PosSearch.search(CATALOG, index, "nut hex"))).toEqual(["a2"]); + }); + + it("finds flat washers from 'washer flat'", () => { + expect(ids(PosSearch.search(CATALOG, index, "washer flat"))).toEqual(["d1"]); + }); +}); + +describe("POS catalog search — punctuation is not a barrier", () => { + it("matches a squished query against rows containing that literal run", () => { + // Tier 2 alone reaches a1 ("M20NUTS1.5PUNF") and a5 ("M20 NUT ZPL + // 25KG", whose spacing squishes away). a2 ("M20 HEX NUT") arrives via + // the step-2 glued split instead — both routes are asserted. + expect(ids(PosSearch.search(CATALOG, index, "M20NUT"))).toEqual([ + "a1", "a2", "a3", "a4", "a5", + ]); + }); + + it("matches across mixed separators", () => { + expect(ids(PosSearch.search(CATALOG, index, "hex-nut-pln"))).toContain("a2"); + }); +}); + +describe("POS catalog search — plural forms (step 2)", () => { + it("offers the singular alongside the plural, original first", () => { + const v = PosSearch._internal.pluralVariants("nuts"); + expect(v[0]).toBe("nuts"); + expect(v).toContain("nut"); + }); + + it("handles the -es ending", () => { + const v = PosSearch._internal.pluralVariants("washers"); + expect(v).toContain("washer"); + }); + + it("leaves short words and -ss words alone", () => { + expect(PosSearch._internal.pluralVariants("gas")).toEqual(["gas"]); + expect(PosSearch._internal.pluralVariants("class")).toEqual(["class"]); + expect(PosSearch._internal.pluralVariants("nuts".slice(0, 3))).toEqual(["nut"]); + }); + + it("widens a plural query to exactly the union with its singular", () => { + // The precision guarantee. On the live catalog "nuts" → 174 and the + // singular "nut" → 174: the plural adds nothing the singular did not + // already find. A fuzzy matcher could not make this claim. + const singular = ids(PosSearch.search(CATALOG, index, "nut")); + const plural = ids(PosSearch.search(CATALOG, index, "nuts")); + expect(plural.sort()).toEqual(singular.sort()); + }); + + it("finds washers when the cashier types the plural", () => { + expect(oldFilter(CATALOG, "washers")).toHaveLength(0); + expect(ids(PosSearch.search(CATALOG, index, "washers flat"))).toEqual(["d1"]); + expect(ids(PosSearch.search(CATALOG, index, "washers"))).toEqual(["d1", "d2"]); + }); +}); + +describe("POS catalog search — glued alphanumeric tokens (step 2)", () => { + it("splits on letter/digit boundaries", () => { + expect(PosSearch._internal.splitGlued("m20nut")).toEqual(["m20", "nut"]); + expect(PosSearch._internal.splitGlued("20nuthdg25kg")).toEqual(["20", "nuthdg", "25", "kg"]); + }); + + it("keeps a short leading letter prefix attached to its digits", () => { + // Thread size stays one part: m20nut -> m20 + nut, never m + 20 + nut. + // Promoting the bare "20" would match any row containing a 20. + expect(PosSearch._internal.splitGlued("m8nut")).toEqual(["m8", "nut"]); + }); + + it("cannot separate two adjacent letter-only words", () => { + // A structural letter/digit split has no way to know "amc" ends and + // "nut" begins. Asserted so the limit is visible rather than implied: + // it yields "amcnut" + "93420", which is still specific enough to be + // useful, but it is NOT "amc" + "nut" + "93420". + expect(PosSearch._internal.splitGlued("amcnut93420")).toEqual(["amcnut", "93420"]); + expect(PosSearch._internal.splitGlued("amcnut93420z")).toEqual(["amcnut", "93420"]); + }); + + it("refuses to split when fewer than 2 usable parts survive", () => { + // "m20" would split to ["m", "20"]; dropping the 1-char part leaves + // one, and degrading to the clause "20" alone would match hundreds of + // unrelated rows. Same for a bare "m8" and a pure-digit token. + expect(PosSearch._internal.splitGlued("m20")).toBeNull(); + expect(PosSearch._internal.splitGlued("m8")).toBeNull(); + expect(PosSearch._internal.splitGlued("nut")).toBeNull(); + expect(PosSearch._internal.splitGlued("20")).toBeNull(); + }); + + it("requires EVERY split part, not any one of them", () => { + // The failure this shape exists to prevent: treating m20/nut as + // alternatives would make "M20NUT" mean "has m20", which is 242 rows. + // Requiring both is what keeps it at the M20 nut family. + const rows = PosSearch.search(CATALOG, index, "M20NUT"); + expect(ids(rows)).toEqual(["a1", "a2", "a3", "a4", "a5"]); + expect(ids(rows)).not.toContain("d1"); // M20 WASHER + expect(ids(rows)).not.toContain("d2"); // M20 WASHER + expect(ids(rows)).not.toContain("e1"); // M.STEEL welding rods + }); + + it("does not let a split query degrade to its numeric fragment", () => { + // "M20" must NOT split into a bare "20" clause, which would match every + // row containing a 20. It stays whole, so it means "contains m20" — + // the M20 nut family plus the two M20 washers, and nothing unrelated. + const rows = PosSearch.search(CATALOG, index, "M20"); + expect(ids(rows)).toEqual(["a1", "a2", "a3", "a4", "a5", "d1", "d2"]); + expect(ids(rows)).not.toContain("e1"); + }); + + it("carries the other tokens along when one of them splits", () => { + // Regression: the split group used to be built from the split parts + // alone, so "M20NUT WASHER" was satisfied by m20 AND nut — returning + // nut rows that contain no washer, and missing the actual washer. + expect(PosSearch._internal.compile("M20NUT WASHER")).toEqual([ + [["m20nut"], ["washer"]], + [["m20"], ["nut"], ["washer"]], + ]); + const rows = PosSearch.search(CATALOG, index, "M20NUT WASHER"); + // d1 is "FLAT WASHER M20 DIN125" — has m20 and washer, no nut, so the + // split group must not rescue it. And no nut row may be returned. + expect(ids(rows)).toEqual([]); + }); + + it("still matches a glued token alongside a compatible word", () => { + const M: Item[] = [ + { id: "m1", sku: "Z1", name: "M20 NUT WASHER SET", description: "M20 NUT WASHER SET" }, + ]; + const mi = PosSearch.buildIndex(M); + expect(ids(PosSearch.search(M, mi, "M20NUT WASHER"))).toEqual(["m1"]); + }); + + it("ranks a direct hit above a split-only match", () => { + const R: Item[] = [ + { id: "s1", sku: "A1", name: "M20NUT PLAIN", description: "M20NUT PLAIN" }, + { id: "s2", sku: "A2", name: "M20 HEX NUT", description: "M20 HEX NUT" }, + ]; + const ri = PosSearch.buildIndex(R); + expect(PosSearch.search(R, ri, "M20NUT").map((r) => r.id)).toEqual(["s1", "s2"]); + }); +}); + +describe("POS catalog search — name is searchable", () => { + it("finds an item by name when its description differs", () => { + // description is "Silicone sealant clear" — searching the name alone + // is what fails on the old filter. + expect(oldFilter(CATALOG, "silicone clear")).toHaveLength(0); + expect(ids(PosSearch.search(CATALOG, index, "silicone clear"))).toEqual(["b2"]); + }); + + it("finds an item that has a name but no description", () => { + expect(ids(PosSearch.search(CATALOG, index, "panel socket"))).toEqual(["c1"]); + }); +}); + +describe("POS catalog search — precision is not traded away", () => { + it("widens a plural only by exactly its singular, never further", () => { + // The regression that ruled out fuzzy matching was "nuts" jumping to + // rows it has no business showing. Plural forms are a closed set — the + // plural result equals the singular result, so a cashier can trust that + // everything on screen really is a nut. + for (const [plural, singular] of [ + ["nuts", "nut"], + ["washers", "washer"], + ] as const) { + const p = ids(PosSearch.search(CATALOG, index, plural)); + const s = ids(PosSearch.search(CATALOG, index, singular)); + expect(p.sort(), `"${plural}" must equal "${singular}"`).toEqual(s.sort()); + } + }); + + it("does not widen a clean single-word query past the catalogue's own words", () => { + // "nuts" here returns every nut row, and only nut rows. + const rows = PosSearch.search(CATALOG, index, "nuts"); + expect(rows).toHaveLength(5); + expect(ids(rows)).not.toContain("d1"); + expect(ids(rows)).not.toContain("d2"); + expect(ids(rows)).not.toContain("e1"); + }); + + it("does not let a stray single character match everything", () => { + // "m-20-nu" tokenises to "m" / "20" / "nu". "m" must be a standalone + // word, not a substring of every "M20" in the catalog. + const rows = PosSearch.search(CATALOG, index, "m-20-nu"); + expect(rows.length).toBeLessThan(CATALOG.length / 2); + expect(ids(rows)).not.toContain("e1"); + }); + + it("keeps unrelated items out of a nut query", () => { + expect(ids(PosSearch.search(CATALOG, index, "m20 nut"))).not.toContain("e1"); + }); +}); + +describe("POS catalog search — ranking", () => { + // Deliberately separate from CATALOG: ranking needs one row that matches + // as a contiguous run and another that matches only on words in a + // different order. In CATALOG both M20-nut rows match "m20 nut" + // contiguously ("M20 NUTS" contains "m20 nut"), so they tie. + const RANKED: Item[] = [ + { id: "r1", sku: "T1", name: "HEX M20 NUT", description: "HEX M20 NUT" }, // word-order only + { id: "r2", sku: "T2", name: "M20 HEX NUT", description: "M20 HEX NUT" }, // contiguous + { id: "r3", sku: "T3", name: "WASHER M20", description: "WASHER M20" }, // not a match + ]; + const rankedIndex = PosSearch.buildIndex(RANKED); + + it("ranks a contiguous whole-query match above a word-order match", () => { + const rows = PosSearch.search(RANKED, rankedIndex, "hex nut"); + expect(rows.map((r) => r.id)).toEqual(["r2", "r1"]); + }); + + it("still drops non-matching rows while ranking", () => { + expect(ids(PosSearch.search(RANKED, rankedIndex, "hex nut"))).not.toContain("r3"); + }); + + it("preserves catalog order within a tier", () => { + const rows = PosSearch.search(CATALOG, index, "m20 nut"); + const order = ids(CATALOG).filter((id) => ids(rows).includes(id)); + expect(ids(rows)).toEqual(order); + }); +}); + +describe("POS catalog search — invariants", () => { + it("returns the input untouched for an empty query", () => { + expect(PosSearch.search(CATALOG, index, "")).toEqual(CATALOG); + expect(PosSearch.search(CATALOG, index, " ")).toEqual(CATALOG); + }); + + it("does not mutate the catalog array or its items", () => { + const snapshot = JSON.stringify(CATALOG); + PosSearch.search(CATALOG, index, "m20 nut"); + expect(JSON.stringify(CATALOG)).toBe(snapshot); + }); + + it("keeps derived strings off the items so IndexedDB stays lean", () => { + for (const it of CATALOG) { + expect(Object.keys(it)).toEqual(["id", "sku", "name", "description", "unit"].filter((k) => k in it)); + } + }); + + it("skips an item missing from the index rather than throwing", () => { + const stray: Item = { id: "zz", sku: "X", name: "X", description: "X" }; + const small = CATALOG.slice(0, 2); + const smallIndex = PosSearch.buildIndex(small); + expect(() => PosSearch.search([...small, stray], smallIndex, "m20")).not.toThrow(); + expect(ids(PosSearch.search([...small, stray], smallIndex, "m20"))).not.toContain("zz"); + }); + + it("is case-insensitive", () => { + // Compared case-against-case rather than against a hardcoded list: what + // "case-insensitive" means is that only the casing differs. + for (const q of ["M20 NUTS", "m20 nuts", "M20 NuTs", "M20NUT"]) { + expect(ids(PosSearch.search(CATALOG, index, q))).toEqual( + ids(PosSearch.search(CATALOG, index, q.toLowerCase())), + ); + } + expect(ids(PosSearch.search(CATALOG, index, "m20 nuts"))).toEqual([ + "a1", "a2", "a3", "a4", "a5", + ]); + }); + + it("handles items with empty or missing text", () => { + const sparse: Item[] = [ + { id: "s1", sku: "", name: "", description: "" }, + { id: "s2", sku: "Z9", name: "Z9 NUT", description: "" }, + ]; + const si = PosSearch.buildIndex(sparse); + expect(() => PosSearch.search(sparse, si, "nut")).not.toThrow(); + expect(ids(PosSearch.search(sparse, si, "nut"))).toEqual(["s2"]); + }); + + it("survives a punctuation-only query instead of throwing", () => { + // "-" and "---" normalise to something non-empty but tokenise to + // nothing, so there is no groups[0][0] to read. This threw a TypeError + // that would have blanked the till catalog. + for (const q of ["-", "--", "---", "/", "///", " - ", "-/-", "@@", "+"]) { + expect(() => PosSearch.search(CATALOG, index, q), `query ${q}`).not.toThrow(); + expect(ids(PosSearch.search(CATALOG, index, q)), `query ${q}`).toEqual(ids(CATALOG)); + } + }); +}); \ No newline at end of file