WFDASH-WEBAPP/src/lib/server/order-quote-match.test.ts
Dave aba3bdca5d fix(orders): parse PocketBase date format explicitly in the matcher
Two findings from review of the quote-line-pairing task:

- quoteToOrderAgeDays: Math.round ties toward +Infinity, so a negative
  half-day gap rounds to -0. Normalize to a clean 0.
- quoteWindowForOrder / quoteToOrderAgeDays relied on `new Date(...)`
  parsing PocketBase's space-separated datetime form
  ("2026-08-29 10:00:00.000Z") the same as ISO 8601. That happens to
  work on V8 but is implementation-defined per spec. Both now go
  through a shared parseFlexibleDate helper that normalizes the
  separator explicitly.

Adds tests pinning both behaviours; the existing 40 tests stay green.
2026-08-29 13:20:40 +02:00

573 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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