WFDASH-WEBAPP/src/lib/pos-search.test.ts
Dave bcb0e1a7d0 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.
2026-10-06 23:36:12 +02:00

389 lines
No EOL
17 KiB
TypeScript

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));
}
});
});