import { describe, it, expect } from "vitest"; import { tokenize, jaccard, priceScore, scoreCandidate, scoreOrderVsQuote, RULE_POINTS, MATCH_CONFIDENCE_THRESHOLD, } 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); }); });