389 lines
17 KiB
TypeScript
389 lines
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));
|
||
|
|
}
|
||
|
|
});
|
||
|
|
});
|