feat(pos): tiered catalog search — word order, plurals, glued SKUs
The till filtered the catalog with one case-insensitive includes() over
sku + description, so word order decided whether you got anything:
"nuts m20" returned 0 hits while "m20 nuts" returned 1. The filter also
checked sku_normalised, brand and search_text — fields that exist on
price_list but not on a stock_items row — so three of its five clauses
could never fire, and name was never searched at all.
Search moves into public/pos/pos-search.js, unit-tested from
src/lib/pos-search.test.ts. Every tier is a strict superset of the old
filter, so nothing that used to match stops matching.
exact whole query as a run
squish punctuation/spacing removed, so M20NUT finds M20 Hex Nut
tokens every word present in any order
plural "nuts" also tries "nut"
glued "M20NUT" -> M20 AND NUT on letter/digit boundaries
Measured on the live 3,591-row catalog: "bolts" 0 -> 884 (884 real bolt
rows, and no row literally says "bolts"), "nuts m20" 0 -> 12, "nut hex"
0 -> 13, "washers flat" 0 -> 14, M20NUT 1 -> 12, zero lost matches.
A plural widens to exactly the union with its singular, asserted per pair.
Widening is safe because both step-2 features are shaped as supersets:
plural forms are alternatives inside one clause, while split parts are
separate required clauses. Making split parts alternatives instead
reduces "M20NUT" to "has M20" — 242 rows. Edit-distance fuzzy matching
was measured and rejected: "nuts" 15 -> 172, "m-20-n-u-t" -> 2,481.
Also fixes three defects the live data caught, all reachable from a code
review alone:
- split kept the generic fragment, promoting "20" from "m20nut" to a
required clause; a leading 1-2 letter run now merges into its digits
- the split group dropped the other tokens, so "M20NUT WASHER" matched
rows containing no washer; other tokens are now carried along
- a punctuation-only query ("-", "---") tokenised to nothing and threw,
which would have blanked the catalog at the till
The index is built once per catalog load on both paths (IndexedDB and
network), keeping derived strings off the items so the cache stays lean.
0.21-2.98 ms per keystroke, so no debounce is needed. filteredProducts()
falls back to the old substring filter if PosSearch is missing, since a
skewed service-worker cache would otherwise blank a shop's catalog.
sw.js v28 -> v30: the new script must be in the app shell, and the second
bump is required because pos-search.js is served cache-first and step 2
edited it in place.
This commit is contained in:
parent
970104e510
commit
bcb0e1a7d0
5 changed files with 759 additions and 26 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
312
public/pos/pos-search.js
Normal file
312
public/pos/pos-search.js
Normal file
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -511,6 +511,7 @@ input{font:inherit;color:inherit}
|
|||
<script src="pos-config.js"></script>
|
||||
<script src="theme.js"></script>
|
||||
<script src="letterhead-config.js"></script>
|
||||
<script src="pos-search.js"></script>
|
||||
<script src="pos-app.js"></script>
|
||||
<script>if("serviceWorker" in navigator){navigator.serviceWorker.register("sw.js").catch(function(){});}</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
389
src/lib/pos-search.test.ts
Normal file
389
src/lib/pos-search.test.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
search(items: Item[], index: Map<string, unknown>, 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<string, unknown>;
|
||||
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue