WFDASH-WEBAPP/src/lib/quote-assistant-review.test.ts
Dave 6604e8b837 fix(quote-assistant): review minors (templates, forced order, bounds, UI)
- M1: fillQuote and the tick refuse templates ("quote is not open
  (template)"); both quote pages gate the fill button with
  canAiFillQuote (new/follow_up/claimed, not a template).
- M4: run summary keeps <= 20 errors of <= 300 chars (boundRunErrors).
- M5: forced runs offer never-tried lines before tried ones.
- M6: counts route 400s on ids that aren't PB record ids.
- M7: a fill never replaces a supplier already on the line.
- M8: warn dot on the AI marker when a pending fill has doubts.
- M9: MCP hint states the real eligibility rule.
- M10: detail-page price dialog re-reads the line before recording an
  AI review.
- M11: exported QUOTE_NOT_OPEN_ERROR_PREFIX / AI_DISABLED_ERROR used by
  the run route.
- M14: prompt textarea maxLength 50000.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:41:28 +02:00

160 lines
4.8 KiB
TypeScript

import { describe, expect, it } from "vitest";
import type { QuoteItemAiFill } from "@/lib/types";
import {
aiRowClass,
buildAiReviewPatch,
canAiFillQuote,
isAiFillPending,
nextPricingSourceOnSave,
} from "./quote-assistant-review";
const NOW = new Date("2026-10-16T12:00:00.000Z");
function fill(over: Partial<QuoteItemAiFill> = {}): QuoteItemAiFill {
return {
status: "filled",
stock_item_id: "s-1",
stock_item_name: "Bolt",
stock_item_sku: "B",
cost_price: 1.89,
selling_price: 3.4,
markup: 80,
supplier_id: "s1",
confidence: "high",
reason: "",
doubts: [],
candidates: [],
description_snapshot: "bolt",
model: "m",
attempted_at: NOW.toISOString(),
...over,
};
}
const pending = { ai_fill: fill(), pricing_source: "ai_pos" };
describe("isAiFillPending", () => {
it("is true only for an unreviewed filled ai_pos line", () => {
expect(isAiFillPending(pending)).toBe(true);
});
it("is false once reviewed", () => {
expect(
isAiFillPending({
ai_fill: fill({ reviewed_at: NOW.toISOString() }),
pricing_source: "ai_pos",
}),
).toBe(false);
});
it("is false when a rep's save flipped pricing_source to manual", () => {
expect(isAiFillPending({ ai_fill: fill(), pricing_source: "manual" })).toBe(false);
});
it("is false for non-filled statuses and missing ai_fill", () => {
expect(isAiFillPending({ ai_fill: fill({ status: "no_match" }), pricing_source: "ai_pos" })).toBe(false);
expect(isAiFillPending({ ai_fill: null, pricing_source: "ai_pos" })).toBe(false);
expect(isAiFillPending({ pricing_source: "ai_pos" })).toBe(false);
});
});
describe("nextPricingSourceOnSave", () => {
it("keeps catalogue and ai_pos, otherwise manual", () => {
expect(nextPricingSourceOnSave("catalogue")).toBe("catalogue");
expect(nextPricingSourceOnSave("ai_pos")).toBe("ai_pos");
expect(nextPricingSourceOnSave("ai_email")).toBe("manual");
expect(nextPricingSourceOnSave("")).toBe("manual");
expect(nextPricingSourceOnSave(undefined)).toBe("manual");
expect(nextPricingSourceOnSave("manual")).toBe("manual");
});
});
describe("buildAiReviewPatch", () => {
const reviewer = { id: "u1", name: "Sam" };
it("records an unchanged acceptance", () => {
const patch = buildAiReviewPatch(
pending,
{ cost_price: 1.89, selling_price: 3.4, supplier: "s1" },
reviewer,
NOW,
);
expect(patch.ai_fill).toMatchObject({
status: "filled",
accepted_unchanged: true,
supplier_changed: false,
reviewed_at: NOW.toISOString(),
reviewed_by: "Sam",
});
});
it("flags a changed selling price", () => {
const patch = buildAiReviewPatch(
pending,
{ cost_price: 1.89, selling_price: 4, supplier: "s1" },
reviewer,
NOW,
);
expect(patch.ai_fill?.accepted_unchanged).toBe(false);
});
it("flags a changed cost price", () => {
const patch = buildAiReviewPatch(
pending,
{ cost_price: 2, selling_price: 3.4, supplier: "s1" },
reviewer,
NOW,
);
expect(patch.ai_fill?.accepted_unchanged).toBe(false);
});
it("flags a different or cleared supplier", () => {
const changed = buildAiReviewPatch(
pending,
{ cost_price: 1.89, selling_price: 3.4, supplier: "s2" },
reviewer,
NOW,
);
expect(changed.ai_fill?.supplier_changed).toBe(true);
const cleared = buildAiReviewPatch(
pending,
{ cost_price: 1.89, selling_price: 3.4, supplier: null },
reviewer,
NOW,
);
expect(cleared.ai_fill?.supplier_changed).toBe(true);
});
it("returns an empty patch for a non-pending line", () => {
const saved = { cost_price: 1, selling_price: 2, supplier: null };
expect(
buildAiReviewPatch({ ai_fill: fill(), pricing_source: "manual" }, saved, reviewer, NOW),
).toEqual({});
expect(
buildAiReviewPatch({ ai_fill: null, pricing_source: "ai_pos" }, saved, reviewer, NOW),
).toEqual({});
});
});
describe("canAiFillQuote", () => {
it.each(["new", "follow_up", "claimed"])("allows an open %s quote", (status) => {
expect(canAiFillQuote({ status, is_template: false })).toBe(true);
});
it.each(["approved", "sent", "template", "processed", ""])("refuses status %s", (status) => {
expect(canAiFillQuote({ status, is_template: false })).toBe(false);
});
it("refuses a template whatever its status", () => {
expect(canAiFillQuote({ status: "new", is_template: true })).toBe(false);
});
it("refuses a missing quote", () => {
expect(canAiFillQuote(null)).toBe(false);
});
});
describe("aiRowClass", () => {
it("highlights pending lines only", () => {
expect(aiRowClass(pending)).toBe("bg-ai/10");
expect(aiRowClass({ ai_fill: fill(), pricing_source: "manual" })).toBe("");
expect(aiRowClass({ ai_fill: null, pricing_source: "" })).toBe("");
});
});