WFDASH-WEBAPP/src/lib/server/order-assistant-core.test.ts

339 lines
10 KiB
TypeScript
Raw Normal View History

import { describe, expect, it } from "vitest";
import {
confidenceToScore,
decideOutcome,
isOrderEligible,
isRealOrderLine,
orderLinesHash,
PLACEHOLDER_LINE_PATTERNS,
type AiOrderMatchDecision,
type OrderItemLite,
} from "@/lib/server/order-assistant-core";
import type { OrderAiQuoteMatch } from "@/lib/types";
const NOW = new Date("2026-10-24T12:00:00.000Z");
function line(over: Partial<OrderItemLite> = {}): OrderItemLite {
return {
id: "ol1",
description: "M16 bolt 60mm",
quantity: 10,
item_status: "pending",
...over,
};
}
describe("PLACEHOLDER_LINE_PATTERNS", () => {
it("covers the two known placeholders", () => {
expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("See attached"))).toBe(true);
expect(PLACEHOLDER_LINE_PATTERNS.some((p) => p.test("Buyer to add items"))).toBe(true);
});
});
describe("isRealOrderLine", () => {
it("is false for an empty description", () => {
expect(isRealOrderLine({ description: "" })).toBe(false);
expect(isRealOrderLine({ description: " " })).toBe(false);
expect(isRealOrderLine({})).toBe(false);
});
it("is false for a placeholder line", () => {
expect(isRealOrderLine({ description: "See attached" })).toBe(false);
expect(isRealOrderLine({ description: "buyer to add items" })).toBe(false);
});
it("is false for a cancelled line", () => {
expect(isRealOrderLine({ description: "M16 bolt", item_status: "cancelled" })).toBe(false);
});
it("is true for a normal pending line", () => {
expect(isRealOrderLine({ description: "M16 bolt 60mm", item_status: "pending" })).toBe(true);
});
});
describe("orderLinesHash", () => {
it("is stable over line order", () => {
const a = orderLinesHash([
{ id: "ol1", description: "Bolt", quantity: 10 },
{ id: "ol2", description: "Nut", quantity: 5 },
]);
const b = orderLinesHash([
{ id: "ol2", description: "Nut", quantity: 5 },
{ id: "ol1", description: "Bolt", quantity: 10 },
]);
expect(a).toBe(b);
});
it("changes when a quantity changes", () => {
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
const b = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 11 }]);
expect(a).not.toBe(b);
});
it("changes when a line is added", () => {
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
const b = orderLinesHash([
{ id: "ol1", description: "Bolt", quantity: 10 },
{ id: "ol2", description: "Nut", quantity: 5 },
]);
expect(a).not.toBe(b);
});
it("changes when a description changes", () => {
const a = orderLinesHash([{ id: "ol1", description: "Bolt", quantity: 10 }]);
const b = orderLinesHash([{ id: "ol1", description: "Nut", quantity: 10 }]);
expect(a).not.toBe(b);
});
});
describe("isOrderEligible", () => {
it("is false when the order already has a quote", () => {
expect(
isOrderEligible({ quote: "q1" }, [line()], { now: NOW }),
).toBe(false);
});
it("is false when there is no real line", () => {
expect(
isOrderEligible({}, [line({ description: "See attached" })], { now: NOW }),
).toBe(false);
expect(isOrderEligible({}, [], { now: NOW })).toBe(false);
});
it("is false when no real line is pending", () => {
expect(
isOrderEligible({}, [line({ item_status: "po_generated" })], { now: NOW }),
).toBe(false);
});
it("is true when a real line is pending", () => {
expect(
isOrderEligible({}, [line({ item_status: "pending" })], { now: NOW }),
).toBe(true);
});
it("ignores non-pending lines when deciding", () => {
expect(
isOrderEligible(
{},
[line({ id: "ol1", item_status: "po_generated" }), line({ id: "ol2" })],
{ now: NOW },
),
).toBe(true);
});
it("is false when the same lines were already matched", () => {
const lines = [line()];
const hash = orderLinesHash(lines);
const ai_quote_match: OrderAiQuoteMatch = {
status: "suggested",
quote_id: "q1",
quote_number: "Q-1",
confidence: "medium",
reason: "",
doubts: [],
lines: [],
auto_linked: false,
lines_hash: hash,
model: "m",
run_at: NOW.toISOString(),
};
expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW })).toBe(false);
});
it("is true for the same lines when forced", () => {
const lines = [line()];
const hash = orderLinesHash(lines);
const ai_quote_match: OrderAiQuoteMatch = {
status: "suggested",
quote_id: "q1",
quote_number: "Q-1",
confidence: "medium",
reason: "",
doubts: [],
lines: [],
auto_linked: false,
lines_hash: hash,
model: "m",
run_at: NOW.toISOString(),
};
expect(isOrderEligible({ ai_quote_match }, lines, { now: NOW, force: true })).toBe(true);
});
it("is true when the lines changed since the last run", () => {
const ai_quote_match: OrderAiQuoteMatch = {
status: "suggested",
quote_id: "q1",
quote_number: "Q-1",
confidence: "medium",
reason: "",
doubts: [],
lines: [],
auto_linked: false,
lines_hash: "stale-hash",
model: "m",
run_at: NOW.toISOString(),
};
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true);
});
it("is false within the error cooldown", () => {
const ai_quote_match: OrderAiQuoteMatch = {
status: "error",
quote_id: null,
quote_number: "",
confidence: null,
reason: "",
doubts: [],
lines: [],
auto_linked: false,
lines_hash: "stale-hash",
model: "m",
run_at: new Date(NOW.getTime() - 30 * 60 * 1000).toISOString(),
error: "boom",
};
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(false);
});
it("is true after the error cooldown", () => {
const ai_quote_match: OrderAiQuoteMatch = {
status: "error",
quote_id: null,
quote_number: "",
confidence: null,
reason: "",
doubts: [],
lines: [],
auto_linked: false,
lines_hash: "stale-hash",
model: "m",
run_at: new Date(NOW.getTime() - 61 * 60 * 1000).toISOString(),
error: "boom",
};
expect(isOrderEligible({ ai_quote_match }, [line()], { now: NOW })).toBe(true);
});
});
describe("decideOutcome", () => {
const QUOTE_LINE_TO_QUOTE = new Map([
["ql1", "q1"],
["ql2", "q1"],
]);
function decision(over: Partial<AiOrderMatchDecision> = {}): AiOrderMatchDecision {
return {
quote_id: "q1",
confidence: "high",
reason: "same items",
doubts: [],
lines: [
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "same" },
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "same" },
],
...over,
};
}
const BASE = {
realLineIds: ["ol1", "ol2"],
quoteLineToQuote: QUOTE_LINE_TO_QUOTE,
orderCompany: "c1",
quoteCompany: "c1",
autoLinkEnabled: true,
};
it("auto-links when everything is sure on one quote", () => {
expect(decideOutcome({ decision: decision(), ...BASE })).toBe("auto_link");
});
it("suggests when one line is only likely", () => {
const d = decision({
lines: [
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "likely", reason: "" },
],
});
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
});
it("suggests when one line is unpaired", () => {
const d = decision({
lines: [
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
{ order_line_id: "ol2", quote_line_id: null, same_item: null, reason: "" },
],
});
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
});
it("suggests when a pair points at another quote's line", () => {
const d = decision({
lines: [
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
{ order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" },
],
});
expect(decideOutcome({ decision: d, ...BASE })).toBe("suggest");
});
it("suggests when the company differs", () => {
expect(
decideOutcome({ decision: decision(), ...BASE, quoteCompany: "c2" }),
).toBe("suggest");
});
it("suggests when the quote has no company", () => {
expect(
decideOutcome({ decision: decision(), ...BASE, quoteCompany: null }),
).toBe("suggest");
});
it("suggests when auto-link is switched off", () => {
expect(
decideOutcome({ decision: decision(), ...BASE, autoLinkEnabled: false }),
).toBe("suggest");
});
it("suggests when the confidence is medium", () => {
expect(
decideOutcome({ decision: decision({ confidence: "medium" }), ...BASE }),
).toBe("suggest");
});
it("is no_match when the AI chose no quote", () => {
expect(
decideOutcome({ decision: decision({ quote_id: null }), ...BASE }),
).toBe("no_match");
});
it("is no_match when no pair lands on the chosen quote", () => {
const d = decision({
lines: [
{ order_line_id: "ol1", quote_line_id: "qlOther", same_item: "sure", reason: "" },
{ order_line_id: "ol2", quote_line_id: "qlOther", same_item: "sure", reason: "" },
],
});
expect(decideOutcome({ decision: d, ...BASE })).toBe("no_match");
});
it("ignores pairs for lines that are not real order lines", () => {
const d = decision({
lines: [
{ order_line_id: "ol1", quote_line_id: "ql1", same_item: "sure", reason: "" },
{ order_line_id: "ol2", quote_line_id: "ql2", same_item: "sure", reason: "" },
{ order_line_id: "olPlaceholder", quote_line_id: "ql1", same_item: "unsure", reason: "" },
],
});
expect(decideOutcome({ decision: d, ...BASE })).toBe("auto_link");
});
});
describe("confidenceToScore", () => {
it("maps the three levels", () => {
expect(confidenceToScore("high")).toBe(0.9);
expect(confidenceToScore("medium")).toBe(0.7);
expect(confidenceToScore("low")).toBe(0.5);
expect(confidenceToScore(null)).toBe(0);
});
});