ci / lint-test (push) Successful in 49s
A Woolworths eReceipt SCREENSHOT has no date anywhere on it — store, total, items, payment, savings, not one date (2026-08-22, batch 60ed68be…) — while the app's own Share button sends the raw receipt with the date line. The card said 'share the top of the screen too', which is delivery-app advice and sends the person back for a shot that cannot exist. Now: the missing field by name, and for a dateless in-store receipt, the Share button or pantry's own upload.
420 lines
22 KiB
TypeScript
420 lines
22 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
||
import { readFileSync } from "fs";
|
||
import { resolve } from "path";
|
||
import {
|
||
classifyInput,
|
||
confabulationReason,
|
||
GROCERY_PATTERN,
|
||
merchantKey,
|
||
merchantMatches,
|
||
platformForSource,
|
||
PROMPT_PLACEHOLDER_MERCHANTS,
|
||
quoteShared,
|
||
reconcileItems,
|
||
resolveMerchantKind,
|
||
textCouldBeReceipt,
|
||
toLineItems,
|
||
validateHeader,
|
||
type ExtractedOrder,
|
||
} from "../../lib/intake-rules";
|
||
import { buildMessageContent, extractPdfText, parseExtraction, EXTRACT_RESPONSE_FORMAT, ExtractionError, liteLLMExtractor, ORDER_ITEM_PROMPT } from "../../lib/intake-extract";
|
||
import { askCard, candidatesCard, enrichedCard, type Block } from "../../lib/intake-blocks";
|
||
|
||
/**
|
||
* The intake router's pure rules, against the strings real receipts print.
|
||
* The two PDFs are the user's own Uber Eats exports (plan §3): a Coles grocery
|
||
* run and a Burgertory meal whose burgers are named after people.
|
||
*/
|
||
|
||
const fixtures = resolve(__dirname, "../fixtures/intake");
|
||
const pdf = (name: string) => readFileSync(resolve(fixtures, name));
|
||
|
||
describe("classifyInput — input type picks the extractor", () => {
|
||
it("PDF by MIME or by name", () => {
|
||
expect(classifyInput({ type: "application/pdf", name: "x" })).toBe("pdf");
|
||
expect(classifyInput({ type: "application/octet-stream", name: "Receipt_18Aug2026.pdf" })).toBe("pdf");
|
||
});
|
||
it("images and text", () => {
|
||
expect(classifyInput({ type: "image/png", name: "IMG_1.png" })).toBe("image");
|
||
expect(classifyInput({ type: "", name: "photo.HEIC" })).toBe("image");
|
||
expect(classifyInput({ type: "text/plain", name: "" })).toBe("text");
|
||
});
|
||
it("declines what it cannot read", () => {
|
||
expect(classifyInput({ type: "video/mp4", name: "clip.mp4" })).toBe("unsupported");
|
||
expect(classifyInput({ type: "application/zip", name: "a.zip" })).toBe("unsupported");
|
||
});
|
||
});
|
||
|
||
describe("platformForSource", () => {
|
||
it("maps the tap to expense_metadata.platform vocabulary", () => {
|
||
expect(platformForSource("uber_eats")).toBe("ubereats");
|
||
expect(platformForSource("doordash")).toBe("doordash");
|
||
expect(platformForSource("coles")).toBeNull();
|
||
expect(platformForSource("other")).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("merchant pattern — grocery | food | ask", () => {
|
||
it("the pattern list is a substring match on the normalised name", () => {
|
||
expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery");
|
||
expect(resolveMerchantKind("other", "Woolworths Group Limited")).toBe("grocery");
|
||
expect(resolveMerchantKind("uber_eats", "WOOLIES METRO")).toBe("grocery");
|
||
expect(resolveMerchantKind("doordash", "ALDI Werribee")).toBe("grocery");
|
||
expect(resolveMerchantKind("uber_eats", "IGA Xpress")).toBe("grocery");
|
||
expect(resolveMerchantKind("uber_eats", "FoodWorks Manor Lakes")).toBe("grocery");
|
||
expect(resolveMerchantKind("uber_eats", "Costco Wholesale")).toBe("grocery");
|
||
});
|
||
it("a delivery order that is not a grocer is a meal", () => {
|
||
expect(resolveMerchantKind("uber_eats", "Burgertory (Werribee)")).toBe("food");
|
||
expect(resolveMerchantKind("doordash", "Mad Mex")).toBe("food");
|
||
});
|
||
it("an empty or unknown merchant asks", () => {
|
||
expect(resolveMerchantKind("uber_eats", null)).toBe("ask");
|
||
expect(resolveMerchantKind("uber_eats", " ")).toBe("ask");
|
||
expect(resolveMerchantKind("doordash", "Unknown")).toBe("ask");
|
||
});
|
||
it("the Coles/Woolworths taps are ground truth", () => {
|
||
expect(resolveMerchantKind("coles", "Romeo's Food Hall")).toBe("grocery");
|
||
expect(resolveMerchantKind("woolworths", null)).toBe("grocery");
|
||
});
|
||
it("`other` with an unrecognised merchant asks; a remembered answer sticks", () => {
|
||
expect(resolveMerchantKind("other", "Romeo's Food Hall")).toBe("ask");
|
||
expect(resolveMerchantKind("other", "Romeo's Food Hall", "grocery")).toBe("grocery");
|
||
expect(resolveMerchantKind("uber_eats", "Romeo's Food Hall", "grocery")).toBe("grocery");
|
||
});
|
||
it("merchantKey folds case and punctuation; merchantMatches allows containment only", () => {
|
||
expect(merchantKey("Coles (Wyndham Vale)")).toBe("coles wyndham vale");
|
||
expect(merchantMatches("Coles (Wyndham Vale)", "coles wyndham vale")).toBe(true);
|
||
expect(merchantMatches("Coles (Wyndham Vale)", "Coles")).toBe(true);
|
||
expect(merchantMatches("Order - Coles (Wyndham Vale)", "Coles (Wyndham Vale)")).toBe(true);
|
||
expect(merchantMatches("Burgertory (Werribee)", "Coles")).toBe(false);
|
||
expect(merchantMatches(null, "Coles")).toBe(false);
|
||
});
|
||
|
||
it("whole words, not substrings: `Bigals Pizza` contains `iga` and is neither a grocer nor an IGA", () => {
|
||
expect(GROCERY_PATTERN.test(merchantKey("Bigals Pizza"))).toBe(false);
|
||
expect(resolveMerchantKind("uber_eats", "Bigals Pizza")).toBe("food");
|
||
expect(merchantMatches("Bigals Pizza", "IGA")).toBe(false);
|
||
// and the grocers a boundary must not cost us
|
||
expect(resolveMerchantKind("uber_eats", "IGA Wyndham Vale")).toBe("grocery");
|
||
expect(resolveMerchantKind("uber_eats", "Woolworths Group Limited")).toBe("grocery");
|
||
expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery");
|
||
expect(merchantMatches("IGA Wyndham Vale", "IGA")).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("validateHeader — nothing is written from a half-read upload", () => {
|
||
const base: ExtractedOrder = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, items: [] };
|
||
it("accepts a complete header and rounds the total", () => {
|
||
const r = validateHeader({ ...base, total: 22.404 });
|
||
expect(r.ok && r.header.total).toBe(22.4);
|
||
});
|
||
it("missing total", () => {
|
||
const r = validateHeader({ ...base, total: null });
|
||
expect(r.ok).toBe(false);
|
||
expect(!r.ok && r.missing).toEqual(["total"]);
|
||
});
|
||
it("missing or malformed date", () => {
|
||
expect(validateHeader({ ...base, order_date: null }).ok).toBe(false);
|
||
expect(validateHeader({ ...base, order_date: "19/8/26" }).ok).toBe(false);
|
||
});
|
||
it("a missing merchant is NOT a missing header — it becomes an ask", () => {
|
||
const r = validateHeader({ ...base, merchant: null });
|
||
expect(r.ok && r.header.merchant).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("reconcileItems — the missing-screenshot check", () => {
|
||
it("reconciles to the printed item subtotal, not the total: fees and promotions are not items", () => {
|
||
// The real Coles export: 15 lines summing $118.87 against a printed $118.94 subtotal and an $85.74 total.
|
||
const r = reconcileItems({
|
||
merchant: "Coles", order_date: "2026-08-18", total: 85.74, subtotal: 118.94,
|
||
items: [{ quantity: 1, description: "a", line_total: 118.87 }],
|
||
});
|
||
expect(r.ok).toBe(true);
|
||
expect(r.basis).toBe("subtotal");
|
||
});
|
||
it("falls back to the total when no subtotal is printed", () => {
|
||
const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }, { quantity: 1, description: "Joel", line_total: 14 }] });
|
||
expect(r.ok).toBe(true);
|
||
expect(r.basis).toBe("total");
|
||
});
|
||
it("holds when the lines fall short — a shot is missing", () => {
|
||
const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, subtotal: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }] });
|
||
expect(r.ok).toBe(false);
|
||
expect(r.reason).toMatch(/14\.00.*28\.00/);
|
||
});
|
||
it("tolerance is $1 or 2%, whichever is larger", () => {
|
||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 9.05 }] }).ok).toBe(true);
|
||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 8.9 }] }).ok).toBe(false);
|
||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 196.5 }] }).ok).toBe(true);
|
||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 195 }] }).ok).toBe(false);
|
||
});
|
||
it("derives a line from quantity × unit price when no line total is printed", () => {
|
||
const r = reconcileItems({ merchant: "x", order_date: "d", total: 13.2, subtotal: 13.2, items: [{ quantity: 3, description: "Coke", unit_price: 4.4 }] });
|
||
expect(r.ok).toBe(true);
|
||
expect(r.items_sum).toBe(13.2);
|
||
});
|
||
it("no items at all is held; priceless lines (pasted text) are not", () => {
|
||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [] }).ok).toBe(false);
|
||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [{ quantity: 1, description: "a" }] }).ok).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("toLineItems — names verbatim, options kept", () => {
|
||
it("keeps a burger called Michael called Michael", () => {
|
||
const out = toLineItems([{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] }]);
|
||
expect(out).toEqual([{ qty: 1, description: "Michael", amount: 14, unit_price: 14, options: ["Milk Bun"] }]);
|
||
});
|
||
it("drops nameless lines and defaults quantity to 1", () => {
|
||
const out = toLineItems([{ quantity: null, description: " ", line_total: 1 }, { quantity: null, description: "Cucumbers Continental", unit_price: 2.9 }]);
|
||
expect(out).toEqual([{ qty: 1, description: "Cucumbers Continental", amount: 2.9, unit_price: 2.9 }]);
|
||
});
|
||
});
|
||
|
||
describe("extraction request and reply", () => {
|
||
it("one call: the prompt first, then every part in the order shared", () => {
|
||
const content = buildMessageContent([
|
||
{ kind: "image", mime: "image/png", bytes: Buffer.from("a"), name: "1.png" },
|
||
{ kind: "text", text: "Total $22.40", name: "r.pdf" },
|
||
{ kind: "image", mime: "image/jpeg", bytes: Buffer.from("b"), name: "2.jpg" },
|
||
]) as { type: string; text?: string; image_url?: { url: string } }[];
|
||
expect(content[0].text).toMatch(/VERBATIM/);
|
||
const images = content.filter((c) => c.type === "image_url").map((c) => c.image_url!.url);
|
||
expect(images).toEqual(["data:image/png;base64,YQ==", "data:image/jpeg;base64,Yg=="]);
|
||
expect(content.findIndex((c) => c.text?.includes("Total $22.40"))).toBeLessThan(content.findIndex((c) => c.image_url?.url.endsWith("Yg==")));
|
||
expect(EXTRACT_RESPONSE_FORMAT.json_schema.strict).toBe(true);
|
||
});
|
||
it("parses a fenced reply and coerces numbers", () => {
|
||
const r = parseExtraction('```json\n{"merchant":"Burgertory (Werribee)","order_date":"2026-08-19","total":"22.40","subtotal":28,"currency":null,"items":[{"quantity":1,"description":"Michael","unit_price":14,"line_total":14,"options":["Milk Bun"]}],"adjustments":[{"label":"Promotion","amount":-8}]}\n```');
|
||
expect(r.total).toBe(22.4);
|
||
expect(r.currency).toBe("AUD");
|
||
expect(r.items[0]).toEqual({ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] });
|
||
expect(r.adjustments).toEqual([{ label: "Promotion", amount: -8 }]);
|
||
});
|
||
});
|
||
|
||
describe("PDF text — the Uber Eats export is born-digital text", () => {
|
||
it("Coles (Wyndham Vale) · 2026-08-18 · $85.74, 15 lines, weighed and substituted", async () => {
|
||
const text = await extractPdfText(pdf("ubereats-coles-2026-08-18.pdf"));
|
||
expect(text).toContain("Here's your receipt for Coles (Wyndham Vale).");
|
||
expect(text).toContain("18 Aug, 2026");
|
||
expect(text).toContain("Total $85.74");
|
||
expect(text).toContain("Item Subtotal $118.94");
|
||
expect(text).toContain("Final 1.16 kg $11.00/kg");
|
||
expect(text).toContain("Replaced Coles Beef Slow Cook Chuck Roast");
|
||
// 15 item lines: 13 priced per piece, 2 weighed per kg.
|
||
expect(text.match(/\$\d+\.\d\d\/pc$/gm)?.length).toBe(13);
|
||
expect(text.match(/\$\d+\.\d\d\/kg$/gm)?.length).toBe(2);
|
||
});
|
||
it("Burgertory (Werribee) · 2026-08-19 · $22.40, burgers named Michael and Joel", async () => {
|
||
const text = await extractPdfText(pdf("ubereats-burgertory-2026-08-19.pdf"));
|
||
expect(text).toContain("Here's your receipt for Burgertory (Werribee).");
|
||
expect(text).toContain("Total $22.40");
|
||
expect(text).toContain("1 Michael $14.00");
|
||
expect(text).toContain("1 Joel $14.00");
|
||
expect(text).toContain("Item Subtotal $28.00");
|
||
});
|
||
});
|
||
|
||
describe("cards carry the C5 action ids and JSON values", () => {
|
||
const c = { batchId: "11111111-2222-4333-8444-555555555555", merchant: "Burgertory (Werribee)", total: 22.4, orderDate: "2026-08-19", platform: "ubereats", itemCount: 2 };
|
||
it("candidates: one Use-this per candidate plus an explicit Create", () => {
|
||
const { blocks } = candidatesCard(c, [{ transaction_id: 42, description: "Order - Burgertory (Werribee)", transaction_date: "2026-08-17", amount: "19.90" }]);
|
||
const buttons = JSON.stringify(blocks);
|
||
expect(buttons).toContain('"action_id":"intake_use_candidate"');
|
||
expect(buttons).toContain('"action_id":"intake_create"');
|
||
const use = (blocks[1] as { accessory: { value: string } }).accessory.value;
|
||
expect(JSON.parse(use)).toEqual({ batch_id: c.batchId, transaction_id: 42 });
|
||
});
|
||
it("enriched: the confirmation text and a View link", () => {
|
||
const { text, blocks } = enrichedCard({ ...c, transactionId: 7 });
|
||
expect(text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓");
|
||
expect(JSON.stringify(blocks)).toContain("/transactions?q=Burgertory");
|
||
});
|
||
it("ask: Groceries / A meal", () => {
|
||
const ids = JSON.stringify(askCard({ ...c, platform: null }).blocks);
|
||
expect(ids).toContain('"action_id":"intake_grocery"');
|
||
expect(ids).toContain('"action_id":"intake_meal"');
|
||
});
|
||
|
||
it("every value the card did not write is escaped, plain_text included", () => {
|
||
const hostile = { ...c, merchant: "Ben & Jerry's <fake>" };
|
||
const { text, blocks } = candidatesCard(hostile, [
|
||
{ transaction_id: 42, description: "SQ *BEN & JERRY <AU>", transaction_date: "2026-08-17", amount: "19.90" },
|
||
]);
|
||
const sections = JSON.stringify(blocks.filter((b) => (b as Block).type === "section"));
|
||
expect(sections).toContain("Ben & Jerry's <fake>");
|
||
expect(sections).toContain("SQ *BEN & JERRY <AU>");
|
||
expect(text).toContain("Ben & Jerry's <fake>");
|
||
// plain_text disables formatting, not entity decoding — &, < and > are
|
||
// control characters everywhere in a Slack payload.
|
||
const whole = JSON.stringify(blocks);
|
||
expect(whole).not.toContain("<fake>");
|
||
expect(whole).not.toContain("<AU>");
|
||
});
|
||
|
||
it("the currency is model output too, so it is escaped like any other", () => {
|
||
// parseExtraction accepts any string for `currency`; unescaped, `<@U123>`
|
||
// would be a live mention on every card the batch produces.
|
||
const { text } = enrichedCard({ ...c, currency: "<@U123>", transactionId: 7 });
|
||
expect(text).toContain("<@U123> 22.40");
|
||
expect(text).not.toContain("<@U123>");
|
||
});
|
||
|
||
it("a foreign receipt is not printed as dollars — header and candidate rows alike", () => {
|
||
expect(enrichedCard({ ...c, currency: "USD", transactionId: 7 }).text).toBe(
|
||
"Added 2 items to your USD 22.40 Uber Eats (Burgertory (Werribee)) order ✓"
|
||
);
|
||
expect(enrichedCard({ ...c, currency: "AUD", transactionId: 7 }).text).toContain("$22.40");
|
||
expect(enrichedCard({ ...c, transactionId: 7 }).text).toContain("$22.40");
|
||
// A candidate row is a ledger transaction and carries its own currency; it
|
||
// used to print "$" beneath a header reading "USD 22.40".
|
||
const { blocks } = candidatesCard({ ...c, currency: "USD" }, [
|
||
{ transaction_id: 42, description: "Order - Burgertory", transaction_date: "2026-08-17", amount: "19.90", currency: "USD" },
|
||
]);
|
||
expect(JSON.stringify(blocks)).toContain("USD 19.90");
|
||
});
|
||
});
|
||
|
||
describe("liteLLMExtractor — a finish_reason that is present must be `stop`", () => {
|
||
const good = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD", items: [], adjustments: [] };
|
||
const extractor = (finish_reason?: string) =>
|
||
liteLLMExtractor({
|
||
baseUrl: "http://litellm.test",
|
||
key: "k",
|
||
fetchImpl: (async () =>
|
||
Response.json({
|
||
choices: [{ message: { content: JSON.stringify(good) }, ...(finish_reason ? { finish_reason } : {}) }],
|
||
})) as unknown as typeof fetch,
|
||
});
|
||
|
||
it("accepts `stop`, and — on purpose — a reply that reports no reason at all", async () => {
|
||
expect((await extractor("stop")([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4);
|
||
expect((await extractor()([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4);
|
||
});
|
||
|
||
it("rejects any other reason, naming it — the strict schema only held for a reply that finished", async () => {
|
||
// An absent reason is NOT rejected: see the comment in intake-extract.ts.
|
||
await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(ExtractionError);
|
||
await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/too long/);
|
||
await expect(extractor("content_filter")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/content_filter/);
|
||
await expect(extractor("tool_calls")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/tool_calls/);
|
||
});
|
||
});
|
||
|
||
describe("textCouldBeReceipt — nothing that cannot be a receipt reaches the model", () => {
|
||
const why = (t: string | null | undefined) => {
|
||
const r = textCouldBeReceipt(t);
|
||
return r.ok ? null : r.reason;
|
||
};
|
||
|
||
it("the live defect: the phone sent the WORD `Image`, not the image", () => {
|
||
expect(textCouldBeReceipt("Image").ok).toBe(false);
|
||
expect(why("Image")).toMatch(/single word/);
|
||
expect(why("Image\n")).toMatch(/single word/);
|
||
});
|
||
|
||
it("a filename is not a receipt, digits in it notwithstanding", () => {
|
||
expect(why("IMG_0042.jpeg")).toMatch(/single word/);
|
||
expect(why("Receipt_19Aug2026.pdf")).toMatch(/single word/);
|
||
});
|
||
|
||
it("empty, blank and whitespace-only", () => {
|
||
expect(why("")).toMatch(/empty/);
|
||
expect(why(" \n\t ")).toMatch(/empty/);
|
||
expect(why(null)).toMatch(/empty/);
|
||
expect(why(undefined)).toMatch(/empty/);
|
||
});
|
||
|
||
it("prose with no numbers cannot be a receipt", () => {
|
||
expect(why("Shared from Photos")).toMatch(/no numbers/);
|
||
expect(why("here is the receipt for dinner last night, sorry it took so long")).toMatch(/no numbers/);
|
||
});
|
||
|
||
it("short but with a printed amount goes through — this door accepts a pasted line", () => {
|
||
expect(textCouldBeReceipt("TOTAL $17.00").ok).toBe(true);
|
||
expect(textCouldBeReceipt("Total 17.00").ok).toBe(true);
|
||
expect(textCouldBeReceipt("Gesamt 12,50 EUR").ok).toBe(true);
|
||
});
|
||
|
||
it("short with digits but no money figure does not — a screenshot name is not a purchase", () => {
|
||
expect(why("Screenshot 2026 08 22")).toMatch(/too short/);
|
||
expect(why("2 items")).toMatch(/too short/);
|
||
});
|
||
|
||
it("a real receipt body passes", () => {
|
||
expect(
|
||
textCouldBeReceipt("Burgertory (Werribee)\n19 Aug 2026\n1x Michael 14.00\n1x Joel 14.00\nSubtotal 28.00\nTotal 22.40").ok
|
||
).toBe(true);
|
||
});
|
||
|
||
it("quoteShared flattens and truncates what goes on the card", () => {
|
||
expect(quoteShared("Image")).toBe("Image");
|
||
expect(quoteShared(" a \n b ")).toBe("a b");
|
||
expect(quoteShared("x".repeat(200))).toHaveLength(80);
|
||
expect(quoteShared("x".repeat(200)).endsWith("…")).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("confabulationReason — the belt to that pre-check", () => {
|
||
const nothing = { textParts: ["Image"], hasImages: false };
|
||
const readable = { textParts: ["Total $22.40 Burgertory (Werribee) 2026-08-19"], hasImages: false };
|
||
const order = { merchant: "Burgertory (Braybrook)", items: [{ description: "The Pride" }] };
|
||
|
||
it("flags an order extracted from nothing readable", () => {
|
||
expect(confabulationReason(nothing, order)).toMatch(/nothing shared could be read/);
|
||
});
|
||
|
||
it("does not flag one extracted from something readable", () => {
|
||
expect(confabulationReason(readable, order)).toBeNull();
|
||
});
|
||
|
||
it("does not flag an image share — the pixels are what we asked for", () => {
|
||
expect(confabulationReason({ textParts: [], hasImages: true }, order)).toBeNull();
|
||
});
|
||
|
||
it("an empty answer to an unreadable share is honest, not a confabulation", () => {
|
||
expect(confabulationReason(nothing, { merchant: null, items: [] })).toBeNull();
|
||
});
|
||
|
||
it("the prompt's own placeholder coming back as a merchant is always a confabulation", () => {
|
||
for (const p of PROMPT_PLACEHOLDER_MERCHANTS) {
|
||
expect(confabulationReason(readable, { merchant: p, items: [] })).toMatch(/prompt's own example/);
|
||
expect(confabulationReason({ textParts: [], hasImages: true }, { merchant: p, items: [] })).toMatch(/prompt's own example/);
|
||
}
|
||
});
|
||
|
||
it("a real merchant that happens to be named in the prompt's platform list is NOT flagged", () => {
|
||
expect(confabulationReason(readable, { merchant: "Coles (Wyndham Vale)", items: [] })).toBeNull();
|
||
expect(confabulationReason(readable, { merchant: "Woolworths", items: [] })).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("the prompt carries no real merchant to echo", () => {
|
||
it("names no shop it could hand back as data", () => {
|
||
expect(ORDER_ITEM_PROMPT).not.toMatch(/Burgertory/);
|
||
expect(ORDER_ITEM_PROMPT).not.toMatch(/Wyndham Vale/);
|
||
expect(ORDER_ITEM_PROMPT).toContain(PROMPT_PLACEHOLDER_MERCHANTS[0]);
|
||
});
|
||
|
||
it("tells the model what to return when the input is not a receipt", () => {
|
||
expect(ORDER_ITEM_PROMPT).toMatch(/NOT a receipt/);
|
||
expect(ORDER_ITEM_PROMPT).toMatch(/Do not construct a plausible order/);
|
||
});
|
||
});
|
||
|
||
describe("needsHeaderCard", () => {
|
||
it("names the missing field and sends a dateless in-store screenshot to the app's Share button", async () => {
|
||
const { needsHeaderCard } = await import("@/lib/intake-blocks");
|
||
const dateless = needsHeaderCard(["date"], "woolworths").text;
|
||
expect(dateless).toContain("no date");
|
||
expect(dateless).toContain("Share button");
|
||
expect(dateless).not.toContain("total");
|
||
const delivery = needsHeaderCard(["total"], "uber_eats").text;
|
||
expect(delivery).toContain("no total");
|
||
expect(delivery).toContain("part of the screen");
|
||
expect(needsHeaderCard().text).toContain("total or date");
|
||
});
|
||
});
|