import { describe, it, expect } from "vitest"; import { tokenize, jaccard, priceScore, scoreCandidate, scoreOrderVsQuote, RULE_POINTS, MATCH_CONFIDENCE_THRESHOLD, pairQuoteLines, LINE_PAIR_CONFIDENT, LINE_PAIR_POSSIBLE, quoteWindowForOrder, quoteToOrderAgeDays, FUZZY_WINDOW_DAYS, FUZZY_WINDOW_WIDEN_DAYS, } from "@/lib/server/order-quote-match"; /** * Unit tests for the pure scoring helpers inside the order ↔ quote * matcher. PB-dependent helpers (findExactMatch, findFuzzyMatches) are * exercised via the live-PB smoke test — here we just lock the math. * * The helpers live in src/lib/server/order-quote-match.ts so they're * testable without touching PocketBase. pb-helpers re-exports them * for the matcher API to use. */ describe("matcher scoring math", () => { // ─── Tokenizer ────────────────────────────────────────────────── it("tokenize drops short / stopword tokens", () => { const tokens = tokenize("M20 x 40 SET CW NUTS for testing"); // "x" dropped (1 char), "for" dropped (stopword), "cw" dropped (2 char) expect(tokens.has("m20")).toBe(true); expect(tokens.has("set")).toBe(true); expect(tokens.has("nuts")).toBe(true); expect(tokens.has("testing")).toBe(true); expect(tokens.has("x")).toBe(false); expect(tokens.has("for")).toBe(false); }); it("tokenize is case-insensitive and punctuation-tolerant", () => { const a = tokenize("Cable Gland M20"); const b = tokenize("cable-gland/m20!"); expect(a).toEqual(b); }); // ─── Jaccard ──────────────────────────────────────────────────── it("jaccard returns 1.0 for identical sets, 0.0 for disjoint", () => { const a = new Set(["m20", "nuts", "bolt"]); expect(jaccard(a, a)).toBe(1); expect(jaccard(a, new Set(["unrelated"]))).toBe(0); }); it("jaccard returns the right overlap ratio", () => { // |a ∩ b| = 1 ("m20"), |a ∪ b| = 5 (m20, nuts, bolt, washer, spring) // → 1/5 = 0.2 const a = new Set(["m20", "nuts", "bolt"]); const b = new Set(["m20", "washer", "spring"]); expect(jaccard(a, b)).toBeCloseTo(1 / 5, 5); }); it("jaccard returns 0 for two empty sets (no signal, not 1)", () => { expect(jaccard(new Set(), new Set())).toBe(0); }); // ─── Price score ─────────────────────────────────────────────── it("priceScore is 1.0 when sums are equal or within 5%", () => { expect(priceScore(100, 100)).toBe(1); expect(priceScore(100, 104)).toBe(1); // 4% diff }); it("priceScore is 0.0 when sums differ by 50%+", () => { expect(priceScore(100, 50)).toBe(0); expect(priceScore(100, 200)).toBe(0); }); it("priceScore falls off linearly between 5% and 50%", () => { // At 27.5% diff (midpoint) the score should be ~0.5. // Linear: 1 - (0.275 - 0.05) / 0.45 = 1 - 0.5 = 0.5 expect(priceScore(100, 72.5)).toBeCloseTo(0.5, 1); }); it("priceScore returns 0 when one side is 0 (no signal)", () => { expect(priceScore(0, 100)).toBe(0); expect(priceScore(100, 0)).toBe(0); }); // ─── Composite score ────────────────────────────────────────── it("scoreCandidate prefers descriptions over price-only match", () => { const orderItems = [ { description: "M20 x 40 nuts", selling_price: 100, quantity: 1 }, ]; // Perfect desc match, perfect price → score ~1 const perfect = scoreCandidate(orderItems, [ { description: "M20 x 40 nuts", selling_price: 100, quantity: 1 }, ]); // Good price but totally different description → score low const priceOnly = scoreCandidate(orderItems, [ { description: "completely different thing", selling_price: 100, quantity: 1 }, ]); expect(perfect).toBeGreaterThan(priceOnly); }); it("scoreCandidate returns 0 for empty order items", () => { expect(scoreCandidate([], [{ description: "anything", selling_price: 100, quantity: 1 }])).toBe(0); }); // ─── Threshold sanity ───────────────────────────────────────── // The rule-based scorer exposes its breakdown to the UI, so an // operator can audit each signal even on a low-confidence match — // that auditability means a more lenient threshold (0.4) is honest // here than it was on the old description+price composite. Pin // the value so any further change forces a conscious edit and a // look at the operator experience. it("confidence threshold is permissive but not zero (0.4) — breakdown-backed", () => { expect(MATCH_CONFIDENCE_THRESHOLD).toBe(0.4); }); }); /* ============================================================================ * scoreOrderVsQuote — rule-based scoring tests * * Each test exercises one branch of the rule budget so a future refactor * can't silently drop a signal. Tests use full point values (RULE_POINTS) * to keep expectations in lock-step with the implementation. * ========================================================================== */ describe("scoreOrderVsQuote — header signals", () => { it("awards full company points when relations match exactly", () => { const r = scoreOrderVsQuote( { companyId: "co-1", customerId: null, items: [{ description: "M20 nuts", selling_price: 5, quantity: 10 }], }, { company: "co-1", customer: null, items: [{ description: "M20 nuts", selling_price: 5, quantity: 10 }], } ); expect(r.company).toBe(RULE_POINTS.company); }); it("does not award customer points when order has no customer", () => { const r = scoreOrderVsQuote( { companyId: "co-1", customerId: null, items: [{ description: "thing", selling_price: 5, quantity: 1 }], }, { company: "co-1", customer: null, items: [{ description: "thing", selling_price: 5, quantity: 1 }], } ); expect(r.customer).toBeNull(); }); it("awards 0 customer when order has a customer but quote doesn't", () => { const r = scoreOrderVsQuote( { companyId: "co-1", customerId: "cu-9", items: [{ description: "x", selling_price: 5, quantity: 1 }], }, { company: "co-1", customer: null, items: [{ description: "x", selling_price: 5, quantity: 1 }], } ); expect(r.customer).toBe(0); }); it("awards full customer points when both sides match", () => { const r = scoreOrderVsQuote( { companyId: "co-1", customerId: "cu-9", items: [{ description: "thing", selling_price: 5, quantity: 1 }], }, { company: "co-1", customer: "cu-9", items: [{ description: "thing", selling_price: 5, quantity: 1 }], } ); expect(r.customer).toBe(RULE_POINTS.customer); }); }); describe("scoreOrderVsQuote — per-line signals", () => { it("awards +2 description when per-line Jaccard >= 0.5", () => { const r = scoreOrderVsQuote( { items: [ { description: "M20 x 40 SET nuts washers bolts", selling_price: 10, quantity: 5 }, ], }, { items: [ // identical tokens { description: "M20 x 40 SET nuts washers bolts", selling_price: 10, quantity: 5 }, ], } ); expect(r.description).toBe(RULE_POINTS.descriptionFull); expect(r.lines[0].descMatched).toBe(true); }); it("awards partial description (1pt) for moderate overlap (0.3..0.5)", () => { // Need tokens that overlap strongly enough to clear the 0.3 floor // but stay below 0.5 to exercise the partial-credits branch. // order tokens {m20, nuts, abc} (3) vs quote tokens {m20, nuts, xyz, qqq} // → |∩|=2 (m20, nuts), |∪|=5 → 2/5 = 0.4 → partial credit (+1). const r = scoreOrderVsQuote( { items: [ { description: "M20 nuts abc", selling_price: 10, quantity: 1 }, ], }, { items: [ { description: "M20 nuts xyz qqq", selling_price: 10, quantity: 1 }, ], } ); expect(r.description).toBe(RULE_POINTS.descriptionPartial); expect(r.lines[0].descMatched).toBe(true); }); it("awards -1 quantity when order line and matched quote line disagree on qty", () => { const r = scoreOrderVsQuote( { items: [{ description: "M20 nuts washers", selling_price: 10, quantity: 5 }], }, { items: [{ description: "M20 nuts washers", selling_price: 10, quantity: 10 }], } ); // Same description (full +2), same price (+2), qty mismatched (-1) expect(r.description).toBe(RULE_POINTS.descriptionFull); expect(r.price).toBe(RULE_POINTS.price); expect(r.quantity).toBe(RULE_POINTS.quantityMismatch); expect(r.lines[0].qtyMatched).toBe(false); }); it("awards 0 price when prices differ by more than 5%", () => { const r = scoreOrderVsQuote( { items: [ { description: "M20 nuts washers", selling_price: 10, quantity: 5 }, ], }, { items: [ { description: "M20 nuts washers", selling_price: 50, quantity: 5 }, ], } ); expect(r.price).toBe(0); expect(r.lines[0].priceMatched).toBe(false); }); }); describe("scoreOrderVsQuote — item-count penalty", () => { it("deducts -2 when item count differs by 50% or more", () => { const r = scoreOrderVsQuote( { items: [ { description: "alpha", selling_price: 1, quantity: 1 }, { description: "beta", selling_price: 1, quantity: 1 }, ], }, { items: [ { description: "alpha", selling_price: 1, quantity: 1 }, { description: "beta", selling_price: 1, quantity: 1 }, // 4 lines (delta=2, max=4, diffRatio=0.5 → penalty) { description: "gamma", selling_price: 1, quantity: 1 }, { description: "delta", selling_price: 1, quantity: 1 }, ], } ); expect(r.itemCountDelta).toBe(RULE_POINTS.itemCountPenalty50); expect(r.orderItemCount).toBe(2); expect(r.quoteItemCount).toBe(4); }); it("deducts -1 when item count differs by 25-50%", () => { // 3 vs 5 (delta=2, max=5, ratio=0.4) → penalty -1 const r = scoreOrderVsQuote( { items: [ { description: "alpha", selling_price: 1, quantity: 1 }, { description: "beta", selling_price: 1, quantity: 1 }, { description: "gamma", selling_price: 1, quantity: 1 }, ], }, { items: [ { description: "alpha", selling_price: 1, quantity: 1 }, { description: "beta", selling_price: 1, quantity: 1 }, { description: "gamma", selling_price: 1, quantity: 1 }, { description: "delta", selling_price: 1, quantity: 1 }, { description: "epsilon", selling_price: 1, quantity: 1 }, ], } ); expect(r.itemCountDelta).toBe(RULE_POINTS.itemCountPenalty25); }); it("does not penalize identical item counts", () => { const r = scoreOrderVsQuote( { items: [ { description: "alpha", selling_price: 1, quantity: 1 }, { description: "beta", selling_price: 1, quantity: 1 }, { description: "gamma", selling_price: 1, quantity: 1 }, ], }, { items: [ { description: "alpha", selling_price: 1, quantity: 1 }, { description: "beta", selling_price: 1, quantity: 1 }, { description: "gamma", selling_price: 1, quantity: 1 }, ], } ); expect(r.itemCountDelta).toBe(0); }); }); describe("scoreOrderVsQuote — normalization & thresholding", () => { it("normalizes to 0..1 with bounded ceilings across order sizes", () => { // A 1-line order that perfectly matches its 1-line quote should // land near 1.0 the same way a 5-line order that perfectly matches // its 5-line quote does — per-line signals dominate raw points // and observedMax scales with line count. const oneLine = scoreOrderVsQuote( { companyId: "co-1", customerId: "cu-9", items: [{ description: "M20 nuts", selling_price: 10, quantity: 5 }], }, { company: "co-1", customer: "cu-9", items: [{ description: "M20 nuts", selling_price: 10, quantity: 5 }], } ); expect(oneLine.confidence).toBeGreaterThan(0.9); expect(oneLine.confidence).toBeLessThanOrEqual(1); }); it("clamps confidence at 1.0 even when raw points exceed observedMax", () => { // Construct a case where header signals exist but only 1 line matches: // company+customer contribute 5, but only 1 line pair → 5 + 5 raw // vs observedMax 5 + 5 = 10 → 1.0. The clamp guarantees the bound. const r = scoreOrderVsQuote( { companyId: "co-1", customerId: "cu-9", items: [ { description: "M20 nuts", selling_price: 10, quantity: 5 }, { description: "excess line one", selling_price: 1, quantity: 1 }, { description: "excess line two", selling_price: 1, quantity: 1 }, ], }, { company: "co-1", customer: "cu-9", items: [{ description: "M20 nuts", selling_price: 10, quantity: 5 }], } ); expect(r.confidence).toBeLessThanOrEqual(1); expect(r.confidence).toBeGreaterThan(0); }); it("returns 0 confidence when both sides have no items", () => { const r = scoreOrderVsQuote( { items: [] }, { items: [{ description: "anything", selling_price: 1, quantity: 1 }] } ); expect(r.confidence).toBe(0); }); it("floors confidence at 0 (raw points can be negative)", () => { // Force negative: company null, customer 0, paired line mismatched qty, // itemCount penalty 50. Should clamp at 0 not go negative. const r = scoreOrderVsQuote( { // No companyId, no customerId → header signals are absent. items: [{ description: "completely different SKU", selling_price: 10, quantity: 5 }], }, { company: "co-1", // mismatched → null customer: "cu-9", // order has no customer → null items: [ { description: "another thing entirely", selling_price: 50, quantity: 50 }, ], } ); expect(r.confidence).toBe(0); }); it("surfaces a breakdown with per-line points the UI can render", () => { const r = scoreOrderVsQuote( { companyId: "co-1", customerId: "cu-9", items: [ { description: "M20 nuts washers", selling_price: 10, quantity: 5 }, ], }, { company: "co-1", customer: "cu-9", items: [ { description: "M20 nuts washers", selling_price: 10, quantity: 5 }, ], } ); expect(r.lines).toHaveLength(1); expect(r.lines[0].descMatched).toBe(true); expect(r.lines[0].priceMatched).toBe(true); expect(r.lines[0].qtyMatched).toBe(true); expect(r.lines[0].points).toBeGreaterThan(0); }); }); describe("scoreOrderVsQuote — needs a paired line", () => { // A quote that shares nothing with the order is not a candidate, // however well the header matches. Same company (+3) plus same // customer (+2) used to score 0.5 with zero paired lines, which is // how one quote ended up suggested to 16 different orders. it("scores 0 when company and customer match but no line pairs (270555-MOB vs Q002997)", () => { const r = scoreOrderVsQuote( { companyId: "co", customerId: "cu", items: [{ description: 'Funnel 150mm PVC round 2 piece Flexible ("6") Round', selling_price: 16.64, quantity: 1 }] }, { company: "co", customer: "cu", items: [{ description: "22mm endurance +", selling_price: 1655.76, quantity: 1 }] }, ); expect(r.company).toBe(RULE_POINTS.company); expect(r.customer).toBe(RULE_POINTS.customer); expect(r.confidence).toBe(0); }); it("scores 0 against a quote with no lines", () => { const r = scoreOrderVsQuote( { companyId: "co", customerId: "cu", items: [{ description: "M16 bolt", selling_price: 3, quantity: 10 }] }, { company: "co", customer: "cu", items: [] }, ); expect(r.confidence).toBe(0); }); it("still scores above threshold when one line pairs", () => { const r = scoreOrderVsQuote( { companyId: "co", customerId: "cu", items: [{ description: "Flat washer M16 zinc", selling_price: 1, quantity: 100 }] }, { company: "co", customer: "cu", items: [{ description: "Flat washer M16 zinc plated", selling_price: 1, quantity: 100 }] }, ); expect(r.confidence).toBeGreaterThanOrEqual(MATCH_CONFIDENCE_THRESHOLD); }); }); describe("pairQuoteLines", () => { it("tiers a near-identical line as confident", () => { const pairs = pairQuoteLines( [{ description: "M20 HEX BOLT GR8.8 ZINC" }], [{ description: "Hex Bolt M20 Gr8.8 Zinc" }], ); expect(pairs).toHaveLength(1); expect(pairs[0].orderLineIdx).toBe(0); expect(pairs[0].quoteLineIdx).toBe(0); expect(pairs[0].tier).toBe("confident"); }); it("tiers a partial-overlap line as possible", () => { const pairs = pairQuoteLines( [{ description: "cable gland brass M20" }], [{ description: "cable gland stainless M25" }], ); expect(pairs).toHaveLength(1); expect(pairs[0].tier).toBe("possible"); expect(pairs[0].jaccard).toBeGreaterThanOrEqual(LINE_PAIR_POSSIBLE); expect(pairs[0].jaccard).toBeLessThan(LINE_PAIR_CONFIDENT); }); it("tiers an exact 0.5 boundary as confident (inclusive)", () => { // order tokens {cable, gland, brass} (3), quote tokens {cable, // gland, brass, steel, nickel, plated} (6) → |∩|=3, |∪|=6 → 3/6 = 0.5 const pairs = pairQuoteLines( [{ description: "cable gland brass" }], [{ description: "cable gland brass steel nickel plated" }], ); expect(pairs).toHaveLength(1); expect(pairs[0].jaccard).toBe(LINE_PAIR_CONFIDENT); expect(pairs[0].tier).toBe("confident"); }); it("returns no pair when descriptions do not overlap", () => { const pairs = pairQuoteLines( [{ description: "galvanised threaded rod 3 metre" }], [{ description: "silicone sealant clear cartridge" }], ); expect(pairs).toEqual([]); }); it("never pairs one quote line to two order lines", () => { const pairs = pairQuoteLines( [ { description: "hex bolt m20 gr8.8 zinc" }, { description: "hex bolt m20 gr8.8 zinc" }, ], [{ description: "hex bolt m20 gr8.8 zinc" }], ); expect(pairs).toHaveLength(1); const quoteIdxs = pairs.map((p) => p.quoteLineIdx); expect(new Set(quoteIdxs).size).toBe(quoteIdxs.length); }); }); describe("quoteWindowForOrder", () => { const ORDER_DATE = "2026-08-29T10:00:00.000Z"; it("looks back the requested number of days from the ORDER date, not from now", () => { const w = quoteWindowForOrder(ORDER_DATE, 90); expect(w.from).toBe("2026-05-31T10:00:00.000Z"); }); it("extends forward by the grace period so a quote raised just after the PO still matches", () => { const w = quoteWindowForOrder(ORDER_DATE, 90); expect(w.to).toBe("2026-09-05T10:00:00.000Z"); }); it("widens correctly for the fallback pass", () => { const w = quoteWindowForOrder(ORDER_DATE, FUZZY_WINDOW_WIDEN_DAYS); expect(new Date(w.from).getTime()).toBeLessThan( new Date(quoteWindowForOrder(ORDER_DATE, FUZZY_WINDOW_DAYS).from).getTime(), ); }); it("throws on an unparseable order date rather than silently matching everything", () => { expect(() => quoteWindowForOrder("not-a-date", 90)).toThrow(/unparseable/i); }); it("accepts PocketBase's space-separated datetime form identically to the ISO T form", () => { const iso = quoteWindowForOrder(ORDER_DATE, 90); const pbForm = quoteWindowForOrder("2026-08-29 10:00:00.000Z", 90); expect(pbForm).toEqual(iso); }); }); describe("quoteToOrderAgeDays", () => { it("returns a positive day count when the quote precedes the order (normal case)", () => { // Quote created 2026-06-01, order created 2026-08-29 → quote first, // order 89 days later. const days = quoteToOrderAgeDays( "2026-06-01T10:00:00.000Z", "2026-08-29T10:00:00.000Z", ); expect(days).toBe(89); }); it("returns a negative day count when the quote is created after the order (inverted case)", () => { // Quote created 5 days after the order. const days = quoteToOrderAgeDays( "2026-08-29T10:00:00.000Z", "2026-08-24T10:00:00.000Z", ); expect(days).toBe(-5); }); it("returns 0 (not NaN) when either input is unparseable", () => { expect(quoteToOrderAgeDays("not-a-date", "2026-08-29T10:00:00.000Z")).toBe(0); expect(quoteToOrderAgeDays("2026-08-29T10:00:00.000Z", "not-a-date")).toBe(0); expect(Number.isNaN(quoteToOrderAgeDays("garbage", "also-garbage"))).toBe(false); }); it("returns a clean 0, not -0, when the quote lands exactly half a day after the order", () => { // Quote created 12h after the order → (order - quote) = -0.5 days. // Math.round(-0.5) is -0 in JS; the function must normalize that. const days = quoteToOrderAgeDays( "2026-08-29T22:00:00.000Z", "2026-08-29T10:00:00.000Z", ); expect(days).toBe(0); expect(Object.is(days, -0)).toBe(false); expect(Object.is(days, 0)).toBe(true); }); it("accepts PocketBase's space-separated datetime form identically to the ISO T form", () => { const iso = quoteToOrderAgeDays( "2026-06-01T10:00:00.000Z", "2026-08-29T10:00:00.000Z", ); const pbForm = quoteToOrderAgeDays( "2026-06-01 10:00:00.000Z", "2026-08-29 10:00:00.000Z", ); expect(pbForm).toBe(iso); expect(pbForm).toBe(89); }); });