srs-api/src/parser/edgeCases.test.ts
Aswin Biju 3640b2d785 feat(whatsapp): read slip photos and add them to the order
Shops can now send handwritten slips as WhatsApp photos. srs-api reads them
through OpenRouter in the background. The model only proposes text: the parser
decides what is an entry, and nothing is saved without the user's Save.
2026-11-04 13:46:50 +05:30

349 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { describe, expect, it } from 'vitest';
import { read } from '../testing/parser';
import { parseOrder } from './parseOrder';
describe('6.1 input hygiene', () => {
it('EC-02 CRLF, CR and mixed line endings read like LF', () => {
const lf = parseOrder('0888\n0057x2\n0003');
for (const t of ['0888\r\n0057x2\r\n0003', '0888\r0057x2\r0003', '0888\r\n0057x2\r0003']) {
expect(parseOrder(t)).toEqual(lf);
}
});
it('EC-03 invisible marks are removed, line numbers unchanged', () => {
const r = parseOrder('‎0888\n08​88 0057');
expect(r.lines.map((l) => [l.ending, l.line])).toEqual([['0888', 1], ['0888', 2], ['0057', 2]]);
});
it('EC-04 tabs and runs of spaces are noise', () => {
expect(read('0888\t\t0057 0003').lines).toEqual(['0888x1', '0057x1', '0003x1']);
});
it('EC-05 full-width digits become ASCII', () => {
expect(read('0888').lines).toEqual(['0888x1']);
});
it('EC-06 Malayalam or Arabic-Indic digits are unreadable', () => {
expect(read('൦൮൮൮ ٠٨٨٨').unreadable).toEqual(['൦൮൮൮:unsupported_digits', '٠٨٨٨:unsupported_digits']);
});
it('EC-07 input above the limit is reported, not read', () => {
const r = parseOrder('0888 '.repeat(30_000));
expect(r.lines).toEqual([]);
expect(r.unreadable[0]?.reason).toBe('input_too_long');
});
});
describe('6.2 prefixes', () => {
it('EC-10 app copy prefix', () => {
expect(read('[12/10, 14:35] Ravi: 0888').lines).toEqual(['0888x1']);
});
it('EC-11 exported prefix', () => {
expect(read('12/10/2026, 2:35 pm - Ravi: 0888').lines).toEqual(['0888x1']);
expect(read('12/10/26, 14:35 - Ravi: 0057x2').lines).toEqual(['0057x2']);
});
it('EC-12 a name with digits is removed completely', () => {
expect(read('[12/10, 14:35] Shop 2 Main: 0888')).toMatchObject({ lines: ['0888x1'], unreadable: [], ignored: [] });
});
it('EC-13 a prefix on every line of a merged forward', () => {
expect(read('[12/10, 14:35] A: 0888\n[12/10, 14:36] B: 0057x2').lines).toEqual(['0888x1', '0057x2']);
});
it('EC-14 a line that only looks like a prefix is not stripped', () => {
expect(read('12/10 0888')).toMatchObject({ lines: ['0888x1'], unreadable: ['12/10:date_or_time'] });
});
it('EC-15 prefix only', () => {
expect(read('[12/10, 14:35] Ravi:')).toEqual({ lines: [], unreadable: [], ignored: [], tickets: 0 });
});
it('EC-16 seconds and am/pm in the time are stripped, never read as 1435', () => {
expect(read('[12/10/26, 2:35:20 pm] Ravi: 0888').lines).toEqual(['0888x1']);
expect(read('[12/10, 14:35:20] Ravi: 0888').lines).toEqual(['0888x1']);
});
});
describe('6.3 endings', () => {
it('EC-21 leading zeros survive', () => {
expect(parseOrder('0007 0000').lines.map((l) => l.ending)).toEqual(['0007', '0000']);
});
it('EC-22 1-3 digits on an entry line are unreadable, never padded', () => {
expect(read('0888 2 888 0003').unreadable).toEqual(['888:stray_number']);
expect(read('7 0888').unreadable).toEqual(['7:stray_number']);
});
it('EC-23 5+ digits are unreadable, never truncated', () => {
expect(read('12345 404595').unreadable).toEqual(['12345:bad_ending_length', '404595:bad_ending_length']);
});
it('EC-24 letters mixed in are unreadable', () => {
expect(read('O888 08S8 0888').unreadable).toEqual(['O888:mixed_characters', '08S8:mixed_characters']);
// 0 right after an ending is a (zero) quantity by the space rule; 888 is still reported, not ignored.
expect(read('0888 0 888').unreadable).toEqual(['0888 0:zero_quantity', '888:stray_number']);
expect(read('0 888')).toMatchObject({ unreadable: [], ignored: ['0', '888'] }); // known limit: alone on a line
});
it('EC-25 ? or x as an unclear digit', () => {
expect(read('08?8 0xx8 0888?').unreadable).toEqual(['08?8:unclear_digit', '0xx8:unclear_digit', '0888?:unclear_digit']);
});
it('EC-26 endings glued together are not split', () => {
expect(read('08880003').unreadable).toEqual(['08880003:bad_ending_length']);
});
it('EC-28 , ; . | are noise; / is not', () => {
expect(read('0888;0057.0003|3472').lines).toEqual(['0888x1', '0057x1', '0003x1', '3472x1']);
expect(read('0888/0057').unreadable).toEqual(['0888/0057:date_or_time']);
});
});
describe('6.4 quantities', () => {
it('EC-30 x and * in every spacing', () => {
expect(read('0057x2 0057 x 2 0057X2 0057×2 0057*2 0057 * 2').lines).toEqual(Array(6).fill('0057x2'));
});
it('EC-32 bracket with spaces', () => {
expect(read('0888 (2) 0888( 2 )').lines).toEqual(['0888x2', '0888x2']);
});
it('EC-33 dash quantity of 1 to 3 digits', () => {
expect(read('0003-2 0003-12 0003-123').lines).toEqual(['0003x2', '0003x12', '0003x123']);
});
it('EC-34 zero quantity in every form', () => {
expect(read('0003-0 0057x0 0888(0) 8910=0').unreadable.map((u) => u.split(':')[1])).toEqual(Array(4).fill('zero_quantity'));
});
it('EC-35 quantity above the limit', () => {
expect(read('0057x5000 0057*1000').unreadable).toEqual(['0057x5000:quantity_too_large', '0057*1000:quantity_too_large']);
});
it('EC-36 a quantity with no ending: unreadable on an entry line, ignored alone', () => {
expect(read('0888 , x2').unreadable).toEqual(['x2:orphan_quantity']);
expect(read('0888\n(3)')).toMatchObject({ lines: ['0888x1'], unreadable: [], ignored: ['(3)'] });
});
it('EC-37 two quantities are not multiplied', () => {
expect(read('0057x2x3 0057x2(3)').unreadable).toEqual(['0057x2x3:double_quantity', '0057x2(3):double_quantity']);
});
it('EC-38 decimal or negative quantities', () => {
expect(read('0057x2.5 0057x-1').unreadable).toEqual(['0057x2.5:mixed_characters', '0057x-1:mixed_characters']);
});
it('EC-39 A,B,C-2 binds the quantity to C only (provisional, Q3)', () => {
expect(read('0888,0057,0003-2').lines).toEqual(['0888x1', '0057x1', '0003x2']);
});
it('EC-39c space quantity is 1 to 3 digits; 4 digits is the next ending', () => {
expect(read('7784 1 7764 12 3367 123').lines).toEqual(['7784x1', '7764x12', '3367x123']);
expect(read('7784 1234').lines).toEqual(['7784x1', '1234x1']);
});
it('EC-39e a quantity on the next line is not a quantity', () => {
expect(read('7784\n1')).toMatchObject({ lines: ['7784x1'], ignored: ['1'] });
});
it('EC-39f = and _ in every spacing', () => {
expect(read('8910=3 8910 = 3 0159_1 0159 _ 1').lines).toEqual(['8910x3', '8910x3', '0159x1', '0159x1']);
});
it('EC-39h 4 digits after = or _ is never a quantity or an ending', () => {
expect(read('8910=1234 0159_1234').unreadable).toEqual(['8910=1234:quantity_too_large', '0159_1234:quantity_too_large']);
});
it('EC-39i separators mixed in one order', () => {
expect(read('3175-1\n0702=1\n0159_1\n7740(2)').lines).toEqual(['3175x1', '0702x1', '0159x1', '7740x2']);
});
});
describe('6.4b a dot as a quantity (decided 4 Nov 2026)', () => {
it('reads a glued dot and 1-3 digits as the quantity', () => {
expect(read('9001.10').lines).toEqual(['9001x10']);
expect(read('9001.5').lines).toEqual(['9001x5']);
expect(read('9001.100').lines).toEqual(['9001x100']);
expect(read('9001.10.').lines).toEqual(['9001x10']);
});
it('a dot and 4 digits is still two endings', () => {
expect(read('0888.0057').lines).toEqual(['0888x1', '0057x1']);
});
it('a dot with a space around it is not a quantity', () => {
expect(read('9001. 10')).toMatchObject({ lines: ['9001x1'], unreadable: ['10:stray_number'] });
expect(read('9001 .10').lines).toEqual(['9001x1']);
});
it('keeps refusing what it cannot be sure of', () => {
expect(read('0057x2.5').lines).toEqual([]);
expect(read('9001.10.5').lines).toEqual([]);
expect(read('9001.0').unreadable).toEqual(['9001.0:zero_quantity']);
expect(read('9001.5000').lines).toEqual(['9001x1', '5000x1']);
expect(read('2026.11.04').lines).toEqual([]);
});
});
describe('6.4c bold text (decided 4 Nov 2026)', () => {
it('reads WhatsApp bold around a whole line', () => {
expect(read('*6379*100*').lines).toEqual(['6379x100']);
expect(read('*0888*').lines).toEqual(['0888x1']);
expect(read('*6379 100*').lines).toEqual(['6379x100']);
expect(read('*6379x100*').lines).toEqual(['6379x100']);
expect(read('*9001.10*').lines).toEqual(['9001x10']);
expect(read('*5104-5106*').lines).toEqual(['5104x1', '5105x1', '5106x1']);
});
it('reads bold around part of a line and several on one line', () => {
expect(read('*6379* 100').lines).toEqual(['6379x100']);
expect(read('6379 *100*').lines).toEqual(['6379x100']);
expect(read('*6379* *100*').lines).toEqual(['6379x100']);
expect(read('*6379*100* *9001*10*').lines).toEqual(['6379x100', '9001x10']);
expect(read('*6379*\n*9001*').lines).toEqual(['6379x1', '9001x1']);
});
it('reads bold after a WhatsApp prefix, and ignores bold words', () => {
expect(read('[12/10, 14:35] Shop 2: *0888*').lines).toEqual(['0888x1']);
expect(read('*Total 50*')).toMatchObject({ lines: [], ignored: ['Total', '50'] });
});
it('leaves a star that is a quantity alone', () => {
expect(read('6379*100').lines).toEqual(['6379x100']);
expect(read('6379 *100').lines).toEqual(['6379x100']);
expect(read('0057 * 2').lines).toEqual(['0057x2']);
expect(read('0057 *2 0003 *5').lines).toEqual(['0057x2', '0003x5']);
});
it('still refuses a bold marker with no partner', () => {
expect(read('*6379*100').lines).toEqual([]);
expect(read('6379*100*').lines).toEqual([]);
});
it('reads digits styled by "fancy text" tools instead of silently ignoring them', () => {
expect(read('\u{1D7D4}\u{1D7D1}\u{1D7D5}\u{1D7D7}*\u{1D7CF}\u{1D7CE}\u{1D7CE}').lines).toEqual(['6379x100']); // bold
expect(read('\u{1D7F2}\u{1D7EF}\u{1D7F3}\u{1D7F5}').lines).toEqual(['6379x1']); // sans-bold
expect(read('\u{1D7FC}\u{1D7F9}\u{1D7FD}\u{1D7FF}').lines).toEqual(['6379x1']); // monospace
});
});
describe('6.5 ranges', () => {
it('EC-41 single-ending range', () => {
expect(read('5104-5104').lines).toEqual(['5104x1']);
});
it('EC-43 no wrap-around', () => {
expect(read('9998-0001').unreadable).toEqual(['9998-0001:bad_range']);
});
it('EC-44 range too large', () => {
expect(read('0000-9999').unreadable).toEqual(['0000-9999:range_too_large']);
expect(read('0000-0099').lines).toHaveLength(100);
expect(read('0000-0100').unreadable).toEqual(['0000-0100:range_too_large']);
});
it('EC-45 crossing a hundred keeps zero padding', () => {
expect(read('0099-0101').lines).toEqual(['0099x1', '0100x1', '0101x1']);
});
it('EC-46 a range with a quantity is unreadable (provisional, Q5)', () => {
expect(read('5104-5106x2').unreadable).toEqual(['5104-5106x2:bad_range']);
});
it('EC-47 a spaced dash between numbers is unreadable, so 5105 is never lost', () => {
expect(read('5104 - 5106').unreadable).toEqual(['5104 - 5106:bad_range']);
expect(read('5104 -5106').unreadable).toEqual(['5104 -5106:bad_range']);
});
it('EC-47b a spaced dash before 1-3 digits is a quantity (owner, 4 Nov 2026)', () => {
for (const text of ['0888 -20', '0888 - 20', '0888- 20']) {
expect(read(text).lines).toEqual(['0888x20']);
expect(read(text).unreadable).toEqual([]);
}
expect(read('0888 - 5 9001 -3').lines).toEqual(['0888x5', '9001x3']);
expect(read('0888 - 0').unreadable).toEqual(['0888 - 0:zero_quantity']);
expect(read('0888 - 12345').unreadable).toEqual(['0888 - 12345:bad_range']);
expect(read('0888 - Ravi').lines).toEqual(['0888x1']);
});
it('EC-48 dash quantity followed by a range', () => {
expect(read('0003-2-5104').unreadable).toEqual(['0003-2-5104:double_quantity']);
});
});
describe('6.6 totals and ignored text', () => {
it('EC-50 every total form is ignored', () => {
for (const t of ['Totel 54 number', 'Total 54', 'total: 54', '54 total', 'Tot-54', 'Total..50', 'Total =20', 'മൊത്തം 29']) {
expect(read(t), t).toMatchObject({ lines: [], unreadable: [] });
}
});
it('EC-51 bare numbers on their own line are ignored', () => {
expect(read('0888\n250')).toMatchObject({ lines: ['0888x1'], unreadable: [], ignored: ['250'] });
});
it('EC-63 a keyword with no number', () => {
expect(read('Total')).toMatchObject({ unreadable: [], ignored: ['Total'] });
});
it('EC-64 split digits are never joined', () => {
expect(read('Total 5 4')).toMatchObject({ unreadable: [], ignored: ['Total', '5', '4'] });
});
it('EC-65 known limit: a 4-digit total is read as an ending (Q8)', () => {
expect(read('Total 1200').lines).toEqual(['1200x1']);
});
it('EC-66 a total on an entry line is unreadable (Q9)', () => {
expect(read('0888 0057 Total 2')).toMatchObject({ lines: ['0888x1', '0057x1'], unreadable: ['2:stray_number'] });
});
});
describe('6.7 repeats', () => {
it('EC-70 two occurrences are both counted', () => {
const r = parseOrder('0888\n0057\n0888');
expect(r.totalTickets).toBe(3);
expect(r.repeated).toEqual([{ ending: '0888', occurrences: 2, totalQty: 2 }]);
});
it('EC-72 a repeat inside a range', () => {
expect(parseOrder('0888 0887-0889').repeated).toEqual([{ ending: '0888', occurrences: 2, totalQty: 2 }]);
});
});
describe('6.8 mixed, merged and noisy input', () => {
it('EC-81 other-script words are ignored', () => {
expect(read('ഇന്നത്തെ 0888')).toMatchObject({ lines: ['0888x1'], unreadable: [] });
});
it('EC-82 emoji are ignored', () => {
expect(read('0888 ✅ 👍')).toMatchObject({ lines: ['0888x1'], unreadable: [], ignored: ['✅', '👍'] });
});
it('EC-83 the same order twice shows as repeats', () => {
expect(parseOrder('0888 0057x2\n0888 0057x2').repeated.map((r) => r.ending)).toEqual(['0888', '0057']);
});
it('EC-84 merged messages with prefixes and totals', () => {
const t = '[12/10, 14:35] A: 0888 0057x2\nTotal 3\n\n[12/10, 14:40] A: 0003-2\n250';
expect(read(t)).toMatchObject({ lines: ['0888x1', '0057x2', '0003x2'], unreadable: [] });
});
it('EC-85 only a total', () => {
expect(read('250')).toEqual({ lines: [], unreadable: [], ignored: ['250'], tickets: 0 });
});
it('EC-86 digits inside words', () => {
expect(read('ab0888cd 0888a').unreadable).toEqual(['ab0888cd:mixed_characters', '0888a:mixed_characters']);
});
it('EC-87 decimals and thousands separators', () => {
expect(read('0888.5 0057').lines).toEqual(['0888x5', '0057x1']); // a glued dot is a quantity, decided 4 Nov
// 1 right after an ending is its quantity (space rule); 200 is reported, never joined into 1200.
expect(read('0057 1,200')).toMatchObject({ lines: ['0057x1'], unreadable: ['200:stray_number'] });
expect(read('1,200')).toMatchObject({ lines: [], ignored: ['1', '200'] });
});
it('EC-88 dates and times inside the body', () => {
expect(read('0888 12/10/2026 14:35').unreadable).toEqual(['12/10/2026:date_or_time', '14:35:date_or_time']);
});
it('a word right after a bracket quantity is separate text', () => {
expect(read('0888(1)Ravi')).toMatchObject({ lines: ['0888x1'], unreadable: [], ignored: ['Ravi'] });
});
});