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.
349 lines
15 KiB
TypeScript
349 lines
15 KiB
TypeScript
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\n0888 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'] });
|
||
});
|
||
});
|