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.
573 lines
20 KiB
TypeScript
573 lines
20 KiB
TypeScript
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);
|
||
});
|
||
});
|