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