mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
154 lines
5.5 KiB
TypeScript
154 lines
5.5 KiB
TypeScript
import {
|
||
buildReceiptPrompt,
|
||
RECEIPT_LIST_JSON_SCHEMA,
|
||
RECEIPT_ROOT_KEY,
|
||
toReceiptRead,
|
||
} from '../../../../src/nest/llm-parse/receipt-read';
|
||
import { RECEIPT_AMOUNT_MAX, RECEIPT_LINES_MAX, RECEIPT_TEXT_MAX } from '@trek/shared';
|
||
|
||
import { describe, it, expect } from 'vitest';
|
||
|
||
describe('toReceiptRead', () => {
|
||
it('keeps a clean answer as it came', () => {
|
||
expect(
|
||
toReceiptRead({
|
||
merchant: 'Café de Flore',
|
||
date: '2026-09-20',
|
||
total: 23.4,
|
||
currency: 'EUR',
|
||
items: [
|
||
{ name: '2 x Espresso', price: 8 },
|
||
{ name: 'Croque', price: 15.4 },
|
||
],
|
||
}),
|
||
).toEqual({
|
||
merchant: 'Café de Flore',
|
||
date: '2026-09-20',
|
||
total: 23.4,
|
||
currency: 'EUR',
|
||
items: [
|
||
{ name: '2 x Espresso', price: 8 },
|
||
{ name: 'Croque', price: 15.4 },
|
||
],
|
||
});
|
||
});
|
||
|
||
it('reads amounts written as text, in either decimal convention', () => {
|
||
expect(toReceiptRead({ total: '12,50 €' })?.total).toBe(12.5);
|
||
expect(toReceiptRead({ total: '1.234,50' })?.total).toBe(1234.5);
|
||
expect(toReceiptRead({ total: '1,234.50' })?.total).toBe(1234.5);
|
||
expect(toReceiptRead({ total: 7, items: [{ name: 'Tea', price: '3,20' }] })?.items).toEqual([
|
||
{ name: 'Tea', price: 3.2 },
|
||
]);
|
||
});
|
||
|
||
it('turns a currency symbol or name into its code, and drops one nobody can convert', () => {
|
||
expect(toReceiptRead({ total: 5, currency: '€' })?.currency).toBe('EUR');
|
||
expect(toReceiptRead({ total: 5, currency: 'euros' })?.currency).toBe('EUR');
|
||
expect(toReceiptRead({ total: 5, currency: 'Lek' })?.currency).toBeNull();
|
||
});
|
||
|
||
it('keeps only a real calendar date', () => {
|
||
expect(toReceiptRead({ total: 5, date: '2026-02-28' })?.date).toBe('2026-02-28');
|
||
for (const date of ['2026-02-30', '28/02/2026', '2026-2-8', ''])
|
||
expect(toReceiptRead({ total: 5, date })?.date).toBeNull();
|
||
});
|
||
|
||
it('leaves out a total that is not a positive amount, and lines without a name or price', () => {
|
||
const read = toReceiptRead({
|
||
merchant: 'Shop',
|
||
total: 0,
|
||
items: [{ name: '', price: 2 }, { name: 'Bread' }, 'x', { name: 'Milk', price: 1.1 }],
|
||
});
|
||
expect(read).toEqual({
|
||
merchant: 'Shop',
|
||
date: null,
|
||
total: null,
|
||
currency: null,
|
||
items: [{ name: 'Milk', price: 1.1 }],
|
||
});
|
||
});
|
||
|
||
it('is null when nothing usable came back', () => {
|
||
for (const raw of [null, undefined, 'text', [], {}, { total: 'n/a', merchant: ' ' }])
|
||
expect(toReceiptRead(raw)).toBeNull();
|
||
});
|
||
|
||
it('leaves out a discount or a free line, written as a number or as text, so the Ticket split can save', () => {
|
||
const read = toReceiptRead({
|
||
total: 10,
|
||
items: [
|
||
{ name: 'Tea', price: 3 },
|
||
{ name: 'Discount', price: -2 },
|
||
{ name: 'Coupon', price: '-2,00' },
|
||
{ name: 'Bag refund', price: '0,50-' },
|
||
{ name: 'Voucher', price: '€ −1.00' },
|
||
{ name: 'Water', price: 0 },
|
||
],
|
||
});
|
||
expect(read?.items).toEqual([{ name: 'Tea', price: 3 }]);
|
||
});
|
||
|
||
it('keeps the sign of a total written as text, and drops a negative one', () => {
|
||
expect(toReceiptRead({ merchant: 'Shop', total: '-12,50' })?.total).toBeNull();
|
||
expect(toReceiptRead({ total: '12,50 €' })?.total).toBe(12.5);
|
||
});
|
||
|
||
it('reads a Swiss whole amount such as "45.-" as positive, while "2,00-" stays a discount', () => {
|
||
const read = toReceiptRead({
|
||
total: 'CHF 45.-',
|
||
items: [
|
||
{ name: 'Fondue', price: '38,-' },
|
||
{ name: 'Coupon', price: '2,00-' },
|
||
],
|
||
});
|
||
expect(read?.total).toBe(45);
|
||
expect(read?.items).toEqual([{ name: 'Fondue', price: 38 }]);
|
||
});
|
||
|
||
it('cuts names, lines and amounts to what the contract takes instead of losing the whole reading', () => {
|
||
const read = toReceiptRead({
|
||
merchant: 'M'.repeat(RECEIPT_TEXT_MAX + 50),
|
||
total: RECEIPT_AMOUNT_MAX * 10,
|
||
items: [
|
||
{ name: 'Gold', price: RECEIPT_AMOUNT_MAX + 1 },
|
||
...Array.from({ length: RECEIPT_LINES_MAX + 20 }, (_, i) => ({
|
||
name: `Line ${i} ${'x'.repeat(RECEIPT_TEXT_MAX)}`,
|
||
price: 1,
|
||
})),
|
||
],
|
||
});
|
||
expect(read?.merchant).toHaveLength(RECEIPT_TEXT_MAX);
|
||
expect(read?.total).toBeNull();
|
||
expect(read?.items).toHaveLength(RECEIPT_LINES_MAX);
|
||
expect(read?.items[0].name).toMatch(/^Line 0 /);
|
||
expect(read?.items.every((line) => line.name.length <= RECEIPT_TEXT_MAX)).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe('the receipt prompt and schema', () => {
|
||
it('pins today so an ambiguous date is read towards it', () => {
|
||
expect(buildReceiptPrompt(new Date('2026-09-24T10:00:00Z'))).toContain('Today is 2026-09-24.');
|
||
});
|
||
|
||
it('spells out the JSON it wants, wrapped in the receipts list for the cloud clients', () => {
|
||
const local = buildReceiptPrompt(new Date('2026-09-24T10:00:00Z'));
|
||
expect(local).toContain('Return ONLY a JSON object of the form { "merchant": ""');
|
||
expect(local).not.toContain(`"${RECEIPT_ROOT_KEY}"`);
|
||
// A server that turns json_schema down reads this prompt alone, in json_object mode.
|
||
const cloud = buildReceiptPrompt(new Date('2026-09-24T10:00:00Z'), true);
|
||
expect(cloud).toContain(`Return ONLY a JSON object of the form { "${RECEIPT_ROOT_KEY}": [ { "merchant": ""`);
|
||
});
|
||
|
||
it('wraps one receipt under the key the cloud clients read', () => {
|
||
expect(RECEIPT_LIST_JSON_SCHEMA.required).toEqual([RECEIPT_ROOT_KEY]);
|
||
expect(RECEIPT_LIST_JSON_SCHEMA.properties[RECEIPT_ROOT_KEY].items.required).toEqual([
|
||
'merchant',
|
||
'date',
|
||
'total',
|
||
'currency',
|
||
'items',
|
||
]);
|
||
});
|
||
});
|