This commit adds the full end-to-end SKU pricing match flow for quote items: - Add new QuoteItem and PriceListRow fields for SKU and pricing metadata - Add sku_normalised to price_list for robust format-agnostic SKU matching - Refactor price list search to use modular search builder and filter helpers - Add database migrations, backfill scripts, and new package commands - Update catalogue search UI and fix quote list accidental click behavior - Add comprehensive unit, smoke, and end-to-end test suites - Update documentation for the new price search and extraction logic
153 lines
4.9 KiB
TypeScript
153 lines
4.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
normaliseSku,
|
|
escapeForPbString,
|
|
classifyMatch,
|
|
matchTierLabel,
|
|
explainMatch,
|
|
} from "@/lib/server/price-list-match";
|
|
|
|
/**
|
|
* Unit tests for the pure helpers inside the SKU matcher.
|
|
* PB-dependent helpers (findPriceListBySku, findFirstPriceListBySku)
|
|
* are exercised via the live-PB smoke test — here we just lock the
|
|
* canonical-form math.
|
|
*
|
|
* The helpers live in src/lib/server/price-list-match.ts (not in
|
|
* price-list-search.ts) because they're used by the re-price route
|
|
* + the matcher-bench smoke script and have a single owner.
|
|
*/
|
|
|
|
describe("normaliseSku", () => {
|
|
it("uppercases ASCII letters", () => {
|
|
expect(normaliseSku("jeitd-06")).toBe("JEITD06");
|
|
expect(normaliseSku("ts-drl-12-55")).toBe("TSDRL1255");
|
|
});
|
|
|
|
it("strips dashes, slashes, dots, spaces, and other non-alphanumerics", () => {
|
|
expect(normaliseSku("JEITD-06")).toBe("JEITD06");
|
|
expect(normaliseSku("JEI/TD/06")).toBe("JEITD06");
|
|
expect(normaliseSku("JEI.TD.06")).toBe("JEITD06");
|
|
expect(normaliseSku("JEI TD 06")).toBe("JEITD06");
|
|
});
|
|
|
|
it("collapses buyer-style casing into the same canonical form", () => {
|
|
expect(normaliseSku("ts-12x55")).toBe("TS12X55");
|
|
expect(normaliseSku("TS-12X55")).toBe("TS12X55");
|
|
expect(normaliseSku("ts12X55")).toBe("TS12X55");
|
|
});
|
|
|
|
it("returns empty string for empty / null / undefined input", () => {
|
|
expect(normaliseSku("")).toBe("");
|
|
expect(normaliseSku(" ")).toBe("");
|
|
expect(normaliseSku(null)).toBe("");
|
|
expect(normaliseSku(undefined)).toBe("");
|
|
});
|
|
|
|
it("preserves digits so a fully numeric SKU round-trips", () => {
|
|
expect(normaliseSku("001-23-456")).toBe("00123456");
|
|
});
|
|
});
|
|
|
|
describe("escapeForPbString", () => {
|
|
it("doubles embedded double quotes", () => {
|
|
expect(escapeForPbString('Acme "Pro" line')).toBe('Acme \\"Pro\\" line');
|
|
});
|
|
it("is a no-op on plain text", () => {
|
|
expect(escapeForPbString("TS-DRL-12-55")).toBe("TS-DRL-12-55");
|
|
});
|
|
it("returns an empty string for an empty input", () => {
|
|
expect(escapeForPbString("")).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("classifyMatch", () => {
|
|
it("returns 1 for an exact SKU match", () => {
|
|
expect(
|
|
classifyMatch("JEITD-06", { sku: "JEITD-06", sku_normalised: "JEITD06" }),
|
|
).toBe(1);
|
|
});
|
|
it("returns 1 when the canonical form matches exactly (raw differs)", () => {
|
|
expect(
|
|
classifyMatch("jeitd06", { sku: "JEITD-06", sku_normalised: "JEITD06" }),
|
|
).toBe(1);
|
|
});
|
|
it("returns 2 when the canonical form is a substring of the row's normalised SKU", () => {
|
|
expect(
|
|
classifyMatch("JEITD", { sku: "JEITD-D11", sku_normalised: "JEITDD11" }),
|
|
).toBe(2);
|
|
});
|
|
it("returns 3 when neither SKU nor normalised is a substring match", () => {
|
|
expect(
|
|
classifyMatch("bosch grinder", {
|
|
sku: "JEITD-06",
|
|
sku_normalised: "JEITD06",
|
|
}),
|
|
).toBe(3);
|
|
});
|
|
});
|
|
|
|
describe("matchTierLabel", () => {
|
|
it("labels Tier 1 'Exact SKU'", () => {
|
|
expect(matchTierLabel(1)).toBe("Exact SKU");
|
|
});
|
|
it("labels Tier 2 'SKU substring'", () => {
|
|
expect(matchTierLabel(2)).toBe("SKU substring");
|
|
});
|
|
it("labels Tier 3 'Description / brand match'", () => {
|
|
expect(matchTierLabel(3)).toBe("Description / brand match");
|
|
});
|
|
});
|
|
|
|
describe("explainMatch", () => {
|
|
const baseMatch = {
|
|
id: "row1",
|
|
sku: "JEITD-06",
|
|
sku_normalised: "JEITD06",
|
|
description: "TWIST DRILL 6MM DIA",
|
|
brand: "JEI",
|
|
category: "twist_drill",
|
|
subcategory: "",
|
|
supplier: "JEISA - WESTSCOTT GROUP",
|
|
cost_price: 100,
|
|
selling_price: 125,
|
|
unit: "each",
|
|
updated: "",
|
|
specifications: { material: "HSS", diameter_mm: 6 },
|
|
search_text: "",
|
|
};
|
|
|
|
it("renders 'exact SKU match' for Tier 1", () => {
|
|
const why = explainMatch("JEITD-06", baseMatch);
|
|
expect(why).toContain("JEITD-06");
|
|
expect(why).toContain("exact SKU match");
|
|
expect(why).toContain("JEISA");
|
|
});
|
|
it("renders 'SKU substring' for Tier 2", () => {
|
|
const why = explainMatch("JEITD", baseMatch);
|
|
expect(why).toContain("JEITD-06");
|
|
expect(why).toContain("SKU substring");
|
|
});
|
|
it("for Tier 3, points at the brand or spec that matched", () => {
|
|
const why = explainMatch("HSS twist", baseMatch);
|
|
expect(why).toContain("JEITD-06");
|
|
// Either brand or material appears, depending on which word matched.
|
|
expect(why).toMatch(/(brand|material):/);
|
|
});
|
|
it("never returns empty", () => {
|
|
const why = explainMatch("XYZNOPE", baseMatch);
|
|
expect(why.length).toBeGreaterThan(0);
|
|
});
|
|
it("caps the explanation length so the picker modal doesn't wrap strangely", () => {
|
|
const long = explainMatch("JEITD-06", {
|
|
...baseMatch,
|
|
supplier: "A very long supplier name ".repeat(20).trim(),
|
|
});
|
|
expect(long.length).toBeLessThanOrEqual(240);
|
|
});
|
|
});
|
|
|
|
// Reference: keep `normaliseSku` reachable through the import
|
|
// list so dead-code elimination doesn't strip it during refactors.
|
|
void normaliseSku;
|
|
|