diff --git a/src/parser/edgeCases.test.ts b/src/parser/edgeCases.test.ts index cdae086..0cd0e11 100644 --- a/src/parser/edgeCases.test.ts +++ b/src/parser/edgeCases.test.ts @@ -188,6 +188,26 @@ describe('6.4d double dash and plus as a quantity (owner, 5 Nov 2026)', () => { }); }); +describe('6.4f slash as a quantity (owner, 5 Nov 2026)', () => { + it('reads / before 1-3 digits after an ending, in every spacing', () => { + for (const text of ['1991/2', '1991 / 2', '1991 /2', '1991/ 2']) expect(read(text).lines).toEqual(['1991x2']); + expect(read('1991/50 1991/999').lines).toEqual(['1991x50', '1991x999']); + }); + it('4 digits after it, a zero or a second slash stays unreadable', () => { + expect(read('0888/0057').unreadable).toEqual(['0888/0057:date_or_time']); + expect(read('1991/0').unreadable).toEqual(['1991/0:zero_quantity']); + expect(read('2026/11/04').unreadable).toEqual(['2026/11/04:date_or_time']); + expect(read('1991/').lines).toEqual([]); + }); + it('a date that does not start with 4 digits is unchanged', () => { + expect(read('12/10/2026').unreadable).toEqual(['12/10/2026:date_or_time']); + expect(read('0888 12/10')).toMatchObject({ lines: ['0888x1'], unreadable: ['12/10:date_or_time'] }); + }); + it('known limit: a year and month (2026/11) reads as ending 2026, quantity 11', () => { + expect(read('2026/11').lines).toEqual(['2026x11']); + }); +}); + describe('6.4e colon as a quantity (owner, 5 Nov 2026)', () => { it('reads : before 1-3 digits after an ending, in every spacing', () => { for (const text of ['1123:5', '1123 : 5', '1123 :5', '1123: 5']) expect(read(text).lines).toEqual(['1123x5']); diff --git a/src/parser/generated.test.ts b/src/parser/generated.test.ts index 2c6e065..42cd6eb 100644 --- a/src/parser/generated.test.ts +++ b/src/parser/generated.test.ts @@ -23,7 +23,7 @@ function generate(seed: number): { text: string; expected: Line[] } { } const ending = pad4(r.int(10000)); const qty = r.int(3) === 0 ? 1 : 1 + r.int(150); - const form = r.pick(['bare', 'x', ' x ', '*', '=', ' = ', '_', '-', '--', ' -- ', '+', ' + ', ':', ' : ', '()', ' ()', 'space'] as const); + const form = r.pick(['bare', 'x', ' x ', '*', '=', ' = ', '_', '-', '--', ' -- ', '+', ' + ', ':', ' : ', '/', ' / ', '()', ' ()', 'space'] as const); const q = String(qty); const rendered = form === 'bare' ? (qty === 1 ? ending : `${ending}x${q}`) diff --git a/src/parser/scan.ts b/src/parser/scan.ts index 8ea5ea9..489b232 100644 --- a/src/parser/scan.ts +++ b/src/parser/scan.ts @@ -116,6 +116,11 @@ export function scanLine(body: string, atoms: Atom[], line: number): ScanItem[] if (isOp(a, '.') && !isNoiseDot(k) && b !== undefined && b.kind === 'num') return withQty(i, b.text, k + 2); + if (isOp(a, '/') && b !== undefined && b.kind === 'num') { + if (b.text.length > 3) return { ok: false, reason: 'date_or_time', next: k + 2 }; + return withQty(i, b.text, k + 2); + } + if (a !== undefined && a.kind === 'op' && QTY_OPS.has(a.text)) { if (b !== undefined && b.kind === 'num') return withQty(i, b.text, k + 2); if (a.glued) return { ok: false, reason: 'mixed_characters', next: k + 1 }; diff --git a/src/routes/ingest.test.ts b/src/routes/ingest.test.ts index a820a71..384480b 100644 --- a/src/routes/ingest.test.ts +++ b/src/routes/ingest.test.ts @@ -187,7 +187,7 @@ describe('POST /ingest: replies', () => { await say('m1', f.shopUser.phone, '8910=3'); expect(sent).toEqual([]); await say('m2', f.shopUser.phone, '?'); - expect(sent).toEqual([{ chatId: chat, message: '✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel' }]); + expect(sent).toEqual([{ chatId: chat, message: '✅ *Got it*\n1 number, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel' }]); }); it('plays the whole dialog through /ingest and saves the order', async () => { @@ -252,7 +252,7 @@ describe('POST /ingest: replies', () => { await say('m2', f.shopUser.phone, '?'); sendFails = false; await say('m3', f.shopUser.phone, '?'); - expect(sent.map((s) => s.message)).toEqual(['✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel']); + expect(sent.map((s) => s.message)).toEqual(['✅ *Got it*\n1 number, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel']); }); it('does not wait for the bridge before answering', async () => { diff --git a/src/routes/ingestPhoto.test.ts b/src/routes/ingestPhoto.test.ts index 14dc979..da74ff8 100644 --- a/src/routes/ingestPhoto.test.ts +++ b/src/routes/ingestPhoto.test.ts @@ -458,7 +458,7 @@ describe('photos sent together', () => { expect(sent).toHaveLength(2); expect(sent[0]).toContain('Read from your 2 photos'); - expect(sent[1]).toContain('*Total:* 3 lines, 10 tickets'); + expect(sent[1]).toContain('*Total:* 3 numbers, 10 tickets'); expect(getConversation(db, f.shopUser.id)?.state).toBe('CONFIRM'); }); diff --git a/src/whatsapp/conversation.test.ts b/src/whatsapp/conversation.test.ts index ab55cdf..54bd1c7 100644 --- a/src/whatsapp/conversation.test.ts +++ b/src/whatsapp/conversation.test.ts @@ -58,7 +58,7 @@ describe('the walk-through from the plan (section 3.3)', () => { '', '*Shop:* Shop B', '*Draw:* TODAY 3 Nov', - '*Total:* 2 lines, 4 tickets', + '*Total:* 2 numbers, 4 tickets', '', '*Tickets*', '8910 - 3', @@ -79,7 +79,7 @@ describe('the walk-through from the plan (section 3.3)', () => { '', '*Shop:* Shop B', '*Draw:* TODAY 3 Nov', - '*Total:* 3 lines, 6 tickets', + '*Total:* 3 numbers, 6 tickets', '', '*Tickets*', '8910 - 3', @@ -208,13 +208,13 @@ describe('collecting', () => { const say = chatAs(f.shopUser.id); say('8910=3'); const [summary] = say('7810=1\ndone'); - expect(summary).toContain('2 lines, 4 tickets'); + expect(summary).toContain('2 numbers, 4 tickets'); }); it('a whole order with done in the very first message needs no menu', () => { const say = chatAs(f.shopUser.id); const [summary] = say('0888\n0057\ndone'); - expect(summary).toContain('2 lines, 2 tickets'); + expect(summary).toContain('2 numbers, 2 tickets'); expect(state(f.shopUser.id)).toBe('CONFIRM'); }); @@ -222,7 +222,7 @@ describe('collecting', () => { const say = chatAs(f.shopUser.id); say('8910=3'); const [summary] = say('0888=2\n7810=1\n1'); - expect(summary).toContain('3 lines, 6 tickets'); + expect(summary).toContain('3 numbers, 6 tickets'); expect(summary).toContain('✅ Everything was read'); expect(state(f.shopUser.id)).toBe('CONFIRM'); }); @@ -303,7 +303,7 @@ describe('unreadable text', () => { expect(again).toContain('⚠️ *Not read, not in the order:*\n 0x38=1'); expect(again).toContain('⚠️ *Left out earlier, not in the order:*\n 08?8=2'); // the new line is unreadable too, so nothing was added to the order - expect(again).toContain('1 line, 3 tickets'); + expect(again).toContain('1 number, 3 ticket'); expect(again).not.toContain('Everything was read'); }); @@ -313,7 +313,7 @@ describe('unreadable text', () => { say('done'); say('2'); const [again] = say('1234=1'); // a forgotten line, not a correction - expect(again).toContain('2 lines, 4 tickets'); + expect(again).toContain('2 numbers, 4 tickets'); expect(again).toContain('⚠️ *Left out earlier, not in the order:*\n 08?8=2'); expect(again).not.toContain('Everything was read'); say('1'); @@ -329,7 +329,7 @@ describe('unreadable text', () => { say('done'); say('2'); const [again] = say('1234=1'); - expect(again).toContain('*Total:* 2 lines, 4 tickets\n✅ Everything was read'); + expect(again).toContain('*Total:* 2 numbers, 4 tickets\n✅ Everything was read'); expect(again).not.toContain('Left out earlier'); }); @@ -363,7 +363,7 @@ describe('repeated endings', () => { const say = chatAs(f.shopUser.id); say('8910=3\n7810=1\n8910=3'); const [summary] = say('done'); - expect(summary).toContain('3 lines, 7 tickets'); + expect(summary).toContain('3 numbers, 7 tickets'); expect(summary).toContain('8910 - 6 (2 times)'); expect(summary).toContain('7810 - 1'); expect(state(f.shopUser.id)).toBe('CONFIRM'); @@ -399,7 +399,7 @@ describe('the menus', () => { say('done'); expect(say('2')).toEqual([msg.addPrompt()]); const [summary] = say('7810=1'); - expect(summary).toContain('2 lines, 4 tickets'); + expect(summary).toContain('2 numbers, 4 tickets'); }); it('1 and 3 keep their meaning while an Add more is pending', () => { @@ -426,7 +426,7 @@ describe('the menus', () => { const [reply] = say('7810=1'); expect(reply).toContain('*Order summary*'); - expect(reply).toContain('*Total:* 2 lines, 4 tickets'); + expect(reply).toContain('*Total:* 2 numbers, 4 tickets'); expect(reply).toContain('7810 - 1'); expect(reply).toContain(SUMMARY_MENU); expect(state(f.shopUser.id)).toBe('CONFIRM'); @@ -444,7 +444,7 @@ describe('the menus', () => { say('8910=3'); say('done'); expect(say('2')).toEqual([msg.addPrompt()]); - expect(say('7810=1')[0]).toContain('*Total:* 2 lines, 4 tickets'); + expect(say('7810=1')[0]).toContain('*Total:* 2 numbers, 4 tickets'); expect(state(f.shopUser.id)).toBe('CONFIRM'); }); diff --git a/src/whatsapp/conversationPhoto.test.ts b/src/whatsapp/conversationPhoto.test.ts index 78a9fe4..da3ad51 100644 --- a/src/whatsapp/conversationPhoto.test.ts +++ b/src/whatsapp/conversationPhoto.test.ts @@ -225,7 +225,7 @@ describe('a readable photo', () => { const replies = photo(id, '9001 2'); expect(replies[0]).toBe(msg.photoAdded('9001 2')); - expect(replies[1]).toContain('*Total:* 2 lines, 7 tickets'); + expect(replies[1]).toContain('*Total:* 2 numbers, 7 tickets'); expect(conv(id)?.state).toBe('CONFIRM'); expect(orders()).toEqual([]); @@ -322,7 +322,7 @@ describe('a photo with no ticket lines', () => { const [reply] = photo(id, slip); expect(reply).toBe(msg.photoNoTickets(slip, notRead, msg.photoOpenOrder(1, 5))); expect(reply).not.toContain('Got it'); - expect(reply).toContain('1 line, 5 tickets so far.'); + expect(reply).toContain('1 number, 5 tickets so far.'); expect(conv(id)).toMatchObject({ state: 'COLLECTING', buffer_text: '6379 5', source: 'whatsapp_text' }); }); diff --git a/src/whatsapp/messages.test.ts b/src/whatsapp/messages.test.ts index 8491955..da5c41b 100644 --- a/src/whatsapp/messages.test.ts +++ b/src/whatsapp/messages.test.ts @@ -56,7 +56,7 @@ describe('summary', () => { '', '*Shop:* Shop B', '*Draw:* TODAY 3 Nov', - '*Total:* 2 lines, 4 tickets', + '*Total:* 2 numbers, 4 tickets', '', '*Tickets*', '8910 - 3', @@ -84,7 +84,7 @@ describe('summary', () => { '', '*Shop:* Shop B', '*Draw:* TODAY 3 Nov', - '*Total:* 3 lines, 6 tickets', + '*Total:* 3 numbers, 6 tickets', '✅ Everything was read', '', '*Tickets*', @@ -105,7 +105,7 @@ describe('summary', () => { '', '*Shop:* Shop B', '*Draw:* TODAY 3 Nov', - '*Total:* 3 lines, 6 tickets', + '*Total:* 3 numbers, 6 tickets', '', '*Tickets*', '8910 - 3', @@ -146,7 +146,7 @@ describe('summary', () => { entries: [{ ending: '0001', qty: 1, times: 1 }], unreadable: [], }); - expect(t).toContain('*Total:* 1 line, 1 ticket\n✅ Everything was read'); + expect(t).toContain('*Total:* 1 number, 1 ticket\n✅ Everything was read'); }); it('caps a long unreadable list and counts the rest', () => { @@ -165,7 +165,7 @@ describe('summary', () => { describe('prompts', () => { it('the collecting reply gives the totals and offers Done and Cancel', () => { - expect(m.progressUpdate(1, 3)).toBe('✅ *Got it*\n1 line, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel'); + expect(m.progressUpdate(1, 3)).toBe('✅ *Got it*\n1 number, 3 tickets so far.\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel'); }); it('shop list is numbered in the order given', () => { expect(m.shopList(['Shop A', 'Shop B'])).toBe('*Which shop?*\n\n1. Shop A\n2. Shop B'); @@ -184,7 +184,7 @@ describe('prompts', () => { ); }); it('reminder shows progress when something was read', () => { - expect(m.reminder(5, 12)).toBe('⏳ *Unfinished order*\n5 lines, 12 tickets so far.\n\n*Reply:*\n1. Done\n2. Cancel'); + expect(m.reminder(5, 12)).toBe('⏳ *Unfinished order*\n5 numbers, 12 tickets so far.\n\n*Reply:*\n1. Done\n2. Cancel'); }); it('reminder omits progress when nothing was read', () => { expect(m.reminder(0, 0)).toBe('⏳ *Unfinished order*\n\n*Reply:*\n1. Done\n2. Cancel'); @@ -193,7 +193,7 @@ describe('prompts', () => { expect(m.splitQuestion(5, 12)).toBe( [ '⏳ *Unfinished order*', - '5 lines, 12 tickets so far.', + '5 numbers, 12 tickets so far.', '', '*Is this message:*', '1. Part of that order', diff --git a/src/whatsapp/messages.ts b/src/whatsapp/messages.ts index e74438b..89ae3d4 100644 --- a/src/whatsapp/messages.ts +++ b/src/whatsapp/messages.ts @@ -43,7 +43,7 @@ export const SUMMARY_MENU = '*Reply:*\n1. Save\n2. Add more\n3. Cancel'; const FINISH_PROMPT = `*When the order is finished, reply:*\n${COLLECTING_MENU}`; function soFar(lineCount: number, ticketCount: number): string[] { - return lineCount > 0 ? [`${plural(lineCount, 'line')}, ${plural(ticketCount, 'ticket')} so far.`] : []; + return lineCount > 0 ? [`${plural(lineCount, 'number')}, ${plural(ticketCount, 'ticket')} so far.`] : []; } export function progressUpdate(lineCount: number, ticketCount: number): string { @@ -92,7 +92,7 @@ export function summary(s: SummaryInput): string { '', `*Shop:* ${s.shopName}`, `*Draw:* ${drawLabel(s.drawDate, s.today)}`, - `*Total:* ${plural(s.lineCount, 'line')}, ${plural(s.ticketCount, 'ticket')}`, + `*Total:* ${plural(s.lineCount, 'number')}, ${plural(s.ticketCount, 'ticket')}`, ]; if (s.unreadable.length === 0 && s.leftOut.length === 0) out.push('✅ Everything was read'); out.push('', '*Tickets*'); diff --git a/src/whatsapp/sweep.test.ts b/src/whatsapp/sweep.test.ts index 3fc0f38..321e062 100644 --- a/src/whatsapp/sweep.test.ts +++ b/src/whatsapp/sweep.test.ts @@ -66,7 +66,7 @@ describe('reminder', () => { expect(await sweepAt(after(10))).toEqual({ reminded: 1, expired: 0 }); expect(sent).toEqual([{ chatId: 'chat-1@lid', message: msg.reminder(2, 4) }]); - expect(sent[0]!.message).toBe('⏳ *Unfinished order*\n2 lines, 4 tickets so far.\n\n*Reply:*\n1. Done\n2. Cancel'); + expect(sent[0]!.message).toBe('⏳ *Unfinished order*\n2 numbers, 4 tickets so far.\n\n*Reply:*\n1. Done\n2. Cancel'); expect(await sweepAt(after(11))).toEqual({ reminded: 0, expired: 0 }); expect(await sweepAt(after(20))).toEqual({ reminded: 0, expired: 0 }); @@ -284,8 +284,8 @@ describe('the reply after a pause', () => { it('words it with the running totals and the menu', () => { const menu = '\n\n*When the order is finished, reply:*\n1. Done\n2. Cancel'; - expect(msg.progressUpdate(8, 20)).toBe('✅ *Got it*\n8 lines, 20 tickets so far.' + menu); - expect(msg.progressUpdate(1, 1)).toBe('✅ *Got it*\n1 line, 1 ticket so far.' + menu); + expect(msg.progressUpdate(8, 20)).toBe('✅ *Got it*\n8 numbers, 20 tickets so far.' + menu); + expect(msg.progressUpdate(1, 1)).toBe('✅ *Got it*\n1 number, 1 ticket so far.' + menu); expect(msg.progressUpdate(0, 0)).toBe('✅ *Got it*' + menu); }); diff --git a/test/fixtures/orders/slash-and-equals-2026-11-05.expected.json b/test/fixtures/orders/slash-and-equals-2026-11-05.expected.json new file mode 100644 index 0000000..99873f1 --- /dev/null +++ b/test/fixtures/orders/slash-and-equals-2026-11-05.expected.json @@ -0,0 +1,10 @@ +{ + "totalTickets": 3, + "lines": [ + "1991 x2 @1", + "3267 x1 @2" + ], + "repeated": [], + "unreadable": [], + "ignored": [] +} diff --git a/test/fixtures/orders/slash-and-equals-2026-11-05.txt b/test/fixtures/orders/slash-and-equals-2026-11-05.txt new file mode 100644 index 0000000..516838d --- /dev/null +++ b/test/fixtures/orders/slash-and-equals-2026-11-05.txt @@ -0,0 +1,2 @@ +1991/2 +3267=1