diff --git a/app/composables/query-state.ts b/app/composables/query-state.ts index 881b938..2b8606b 100644 --- a/app/composables/query-state.ts +++ b/app/composables/query-state.ts @@ -56,10 +56,15 @@ export function asOneOf(options: readonly T[]) { export const asRange = { parse: (raw: string): [number, number] => { - const [lo, hi] = raw.split('-').map(Number) - if (lo === undefined || hi === undefined) throw new Error('bad range') - if (!Number.isFinite(lo) || !Number.isFinite(hi)) + const [loRaw, hiRaw, extra] = raw.split('-', 3) + if (!loRaw?.trim() || !hiRaw?.trim() || extra !== undefined) throw new Error('bad range') + + const lo = Number(loRaw) + const hi = Number(hiRaw) + if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo > hi) + throw new Error('bad range') + return [lo, hi] }, serialize: ([lo, hi]: [number, number]) => `${lo}-${hi}`, diff --git a/scripts/tests/query-state.test.ts b/scripts/tests/query-state.test.ts new file mode 100644 index 0000000..7f849bf --- /dev/null +++ b/scripts/tests/query-state.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { asRange } from '../../app/composables/query-state.ts' + +describe('stroke range query parsing', () => { + it.each([ + ['1-36', [1, 36]], + ['10-10', [10, 10]], + ])('accepts %s', (raw, expected) => { + expect(asRange.parse(raw)).toEqual(expected) + }) + + it.each([ + // Reversed bounds would match nothing yet read as a legitimate empty table. + '20-10', + // A third segment would otherwise be dropped without a trace. + '1-2-3', + 'x-10', + '10-x', + // Number('') is 0, so an empty segment must be rejected before conversion. + '-10', + '10-', + '0-', + '-', + ])('rejects %s so the query falls back to the default', (raw) => { + expect(() => asRange.parse(raw)).toThrow() + }) +})