diff --git a/src/parser/edgeCases.test.ts b/src/parser/edgeCases.test.ts index 11a992e..cdae086 100644 --- a/src/parser/edgeCases.test.ts +++ b/src/parser/edgeCases.test.ts @@ -161,6 +161,51 @@ describe('6.4 quantities', () => { }); }); +describe('6.4d double dash and plus as a quantity (owner, 5 Nov 2026)', () => { + it('reads -- and + before 1-3 digits as the quantity', () => { + expect(read('1921--50 5149--40 5328+12').lines).toEqual(['1921x50', '5149x40', '5328x12']); + expect(read('1921--5 1921--500 5328+1 5328+999').lines).toEqual(['1921x5', '1921x500', '5328x1', '5328x999']); + }); + it('allows spaces around them, like a single dash', () => { + for (const text of ['1921 -- 50', '1921 --50', '1921-- 50']) expect(read(text).lines).toEqual(['1921x50']); + for (const text of ['5328 + 12', '5328 +12', '5328+ 12']) expect(read(text).lines).toEqual(['5328x12']); + }); + it('never reads 4 digits after them as an ending or a range', () => { + expect(read('1921--1925').unreadable).toEqual(['1921--1925:bad_range']); + expect(read('1921 -- 1925').unreadable).toEqual(['1921 -- 1925:bad_range']); + expect(read('5328+5329').unreadable).toEqual(['5328+5329:quantity_too_large']); + expect(read('5328 + 5329').unreadable).toEqual(['5328 + 5329:quantity_too_large']); + }); + it('keeps refusing what it cannot be sure of', () => { + expect(read('1921--0 5328+0').unreadable).toEqual(['1921--0:zero_quantity', '5328+0:zero_quantity']); + expect(read('1921---50').lines).toEqual([]); + expect(read('1921--').lines).toEqual([]); + expect(read('5328+12+3 1921--50--3').unreadable).toEqual(['5328+12+3:double_quantity', '1921--50--3:double_quantity']); + expect(read('0888, +12').unreadable).toEqual(['+12:orphan_quantity']); + }); + it('a single dash still makes a range', () => { + expect(read('1921-1923').lines).toEqual(['1921x1', '1922x1', '1923x1']); + }); +}); + +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']); + expect(read('1123:50 1123:999').lines).toEqual(['1123x50', '1123x999']); + }); + it('never reads 4 digits after it as an ending, and refuses the rest', () => { + expect(read('1123:1234').unreadable).toEqual(['1123:1234:quantity_too_large']); + expect(read('1123:0').unreadable).toEqual(['1123:0:zero_quantity']); + expect(read('1123:5:30').unreadable).toEqual(['1123:5:30:date_or_time']); + expect(read('1123:').lines).toEqual([]); + }); + it('a time or date without a 4-digit ending before it is still date_or_time', () => { + expect(read('14:35').unreadable).toEqual(['14:35:date_or_time']); + expect(read('0888 14:35')).toMatchObject({ lines: ['0888x1'], unreadable: ['14:35:date_or_time'] }); + expect(read('[12/10, 14:35] Ravi: 1123:5').lines).toEqual(['1123x5']); + }); +}); + 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']); diff --git a/src/parser/generated.test.ts b/src/parser/generated.test.ts index d053b3b..2c6e065 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/lexer.ts b/src/parser/lexer.ts index 2ddf81a..47d1140 100644 --- a/src/parser/lexer.ts +++ b/src/parser/lexer.ts @@ -2,7 +2,7 @@ export type AtomKind = | 'num' // run of ASCII digits | 'baddigit' // run of non-ASCII digits (Malayalam, Arabic-Indic, Devanagari) | 'word' // run of anything else that is not an operator, separator or space (letters, emoji, ?, ...) - | 'op' // one of x * = _ - ( ) / : . (x only when it stands alone as a word) + | 'op' // one of x * = _ + - ( ) / : . (x only when it stands alone as a word) | 'sep'; // , ; | export interface Atom { @@ -13,7 +13,7 @@ export interface Atom { glued: boolean; } -const OPS = new Set(['*', '=', '_', '-', '(', ')', '/', ':', '.']); +const OPS = new Set(['*', '=', '_', '+', '-', '(', ')', '/', ':', '.']); const SEPS = new Set([',', ';', '|']); function isAsciiDigit(c: number): boolean { diff --git a/src/parser/scan.ts b/src/parser/scan.ts index 3adcde9..8ea5ea9 100644 --- a/src/parser/scan.ts +++ b/src/parser/scan.ts @@ -17,8 +17,8 @@ interface Loose { type Item = ScanItem | Loose; -const QTY_OPS = new Set(['x', '*', '=', '_']); -const CONTINUATION_OPS = new Set(['x', '*', '=', '_', '(', '-']); +const QTY_OPS = new Set(['x', '*', '=', '_', '+', ':']); +const CONTINUATION_OPS = new Set(['x', '*', '=', '_', '+', '(', '-']); type Step = { ok: true; item: ScanItem; next: number } | { ok: false; reason: UnreadableReason; next: number }; @@ -88,6 +88,13 @@ export function scanLine(body: string, atoms: Atom[], line: number): ScanItem[] const a = at(k); const b = at(k + 1); + if (isOp(a, '-') && isOp(b, '-') && (b as Atom).glued) { + const c = at(k + 2); + if (c === undefined || c.kind !== 'num') return { ok: false, reason: 'mixed_characters', next: k + 2 }; + if (c.text.length > 3) return { ok: false, reason: 'bad_range', next: k + 3 }; + return withQty(i, c.text, k + 3); + } + if (isOp(a, '-')) { const gluedBoth = (a as Atom).glued && b !== undefined && b.glued; if (b !== undefined && b.kind === 'num') { diff --git a/test/fixtures/orders/colon-2026-11-05.expected.json b/test/fixtures/orders/colon-2026-11-05.expected.json new file mode 100644 index 0000000..b753424 --- /dev/null +++ b/test/fixtures/orders/colon-2026-11-05.expected.json @@ -0,0 +1,9 @@ +{ + "totalTickets": 5, + "lines": [ + "1123 x5 @1" + ], + "repeated": [], + "unreadable": [], + "ignored": [] +} diff --git a/test/fixtures/orders/colon-2026-11-05.txt b/test/fixtures/orders/colon-2026-11-05.txt new file mode 100644 index 0000000..9b87d9f --- /dev/null +++ b/test/fixtures/orders/colon-2026-11-05.txt @@ -0,0 +1 @@ +1123:5 diff --git a/test/fixtures/orders/double-dash-and-plus-2026-11-05.expected.json b/test/fixtures/orders/double-dash-and-plus-2026-11-05.expected.json new file mode 100644 index 0000000..e9c48b3 --- /dev/null +++ b/test/fixtures/orders/double-dash-and-plus-2026-11-05.expected.json @@ -0,0 +1,11 @@ +{ + "totalTickets": 102, + "lines": [ + "1921 x50 @1", + "5149 x40 @2", + "5328 x12 @3" + ], + "repeated": [], + "unreadable": [], + "ignored": [] +} diff --git a/test/fixtures/orders/double-dash-and-plus-2026-11-05.txt b/test/fixtures/orders/double-dash-and-plus-2026-11-05.txt new file mode 100644 index 0000000..c65dc6d --- /dev/null +++ b/test/fixtures/orders/double-dash-and-plus-2026-11-05.txt @@ -0,0 +1,3 @@ +1921--50 +5149--40 +5328+12