From a66479fe607c160414f75b8f1abdc151ca3aa538 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Mon, 21 Aug 2023 02:14:20 +0800 Subject: [PATCH] Add edge cases Add tests related to error handling. --- packages/core/src/common.ts | 19 +++++++- packages/core/src/converter.ts | 4 +- .../src/systems/en-UK/long-count/parse.ts | 26 ++++++---- .../src/systems/en-US/short-count/parse.ts | 47 ++++++++++++------- .../systems/en-US/short-count/stringify.ts | 2 +- .../test/systems/en-UK/long-count.test.ts | 29 ++++++++++++ .../test/systems/en-UK/short-count.test.ts | 32 +++++++++++++ .../test/systems/en-US/short-count.test.ts | 31 ++++++++++++ 8 files changed, 159 insertions(+), 31 deletions(-) diff --git a/packages/core/src/common.ts b/packages/core/src/common.ts index e3cb9d5..b63edf1 100644 --- a/packages/core/src/common.ts +++ b/packages/core/src/common.ts @@ -45,6 +45,20 @@ export const GROUP_DIGITS_INDEX = 0 as const; */ export const GROUP_PLACE_INDEX = 1 as const; +/** + * Result of parsing a number string. + */ +export interface ParseResult { + /** + * The parsed groups. + */ + groups: Group[]; + /** + * Whether the number is negative. + */ + negative: boolean; +} + /** * System for stringifying and parsing numbers. */ @@ -90,14 +104,15 @@ export interface NumberNameSystem { * @see {NumberNameSystem.stringifyGroups} * @returns Group[] The parsed groups. */ - parseGroups: (tokens: string[]) => Group[]; + parseGroups: (tokens: string[]) => ParseResult; /** * Combines groups into a string. * @param groups - The groups to combine. + * @param negative - Whether the number is negative. * @see {NumberNameSystem.splitIntoGroups} * @returns string The combined groups in exponential form. */ - combineGroups: (groups: Group[]) => string; + combineGroups: (groups: Group[], negative: boolean) => string; } /** diff --git a/packages/core/src/converter.ts b/packages/core/src/converter.ts index 82e17e0..a175ff3 100644 --- a/packages/core/src/converter.ts +++ b/packages/core/src/converter.ts @@ -113,8 +113,8 @@ export const parse = (value: string, options = {} as ParseOptions) => { const { system = enUS.shortCount, type = 'string' } = options; const tokens = system.tokenize(value); - const groups = system.parseGroups(tokens); - const stringValue = system.combineGroups(groups); + const { groups, negative } = system.parseGroups(tokens); + const stringValue = system.combineGroups(groups, negative); switch (type) { case 'number': { diff --git a/packages/core/src/systems/en-UK/long-count/parse.ts b/packages/core/src/systems/en-UK/long-count/parse.ts index c03c477..97f91be 100644 --- a/packages/core/src/systems/en-UK/long-count/parse.ts +++ b/packages/core/src/systems/en-UK/long-count/parse.ts @@ -16,7 +16,7 @@ import { ILLION_SUFFIX, MILLIA_PREFIX, MILLIONS_PREFIXES, - MILLIONS_SPECIAL_PREFIXES, + MILLIONS_SPECIAL_PREFIXES, NEGATIVE, NEGATIVE_SYMBOL, ONES, OnesName, @@ -260,6 +260,7 @@ interface ParserState { lastToken?: string; groups: Group[]; mode: ParseGroupsMode; + negative: boolean; } const parseThousand = (acc: ParserState, token: string): ParserState => { @@ -404,7 +405,7 @@ const parseTens = (acc: ParserState, token: string): ParserState => { export const parseGroups = (tokens: string[]) => { // We add a final token which is an empty string to parse whatever the last non-empty token is. const tokensToParse = [...tokens, FINAL_TOKEN]; - const { groups } = tokensToParse.reduce( + const { groups, negative } = tokensToParse.reduce( (acc, token) => { if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { return parseThousand(acc, token); @@ -430,6 +431,13 @@ export const parseGroups = (tokens: string[]) => { return parseTens(acc, token); } + if (token === NEGATIVE) { + return { + ...acc, + negative: !acc.negative, + }; + } + return { ...acc, lastToken: token, @@ -439,23 +447,25 @@ export const parseGroups = (tokens: string[]) => { lastToken: undefined, groups: [], mode: ParseGroupsMode.INITIAL, + negative: false, }, ); - return groups; + return { groups, negative }; }; /** * Combines groups into a string. * @param groups - The groups to combine. + * @param negative - Whether the number is negative. * @see {NumberNameSystem.splitIntoGroups} * @returns string The combined groups in exponential form. */ -export const combineGroups = (groups: Group[]) => { - const places = groups.map((g) => g[GROUP_PLACE_INDEX]); - if (places.length < 1) { +export const combineGroups = (groups: Group[], negative: boolean) => { + if (groups.length < 1) { return ''; } + const places = groups.map((g) => g[GROUP_PLACE_INDEX]); const maxPlace = bigIntMax(...places) as bigint; const minPlace = bigIntMin(...places) as bigint; const firstGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === maxPlace) ?? [...EMPTY_PLACE]; @@ -486,7 +496,7 @@ export const combineGroups = (groups: Group[]) => { const significandInteger = digits.slice(0, 1); const significandFraction = digits.slice(1).replace(/0+$/, ''); if (significandFraction.length > 0) { - return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; + return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; } - return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`; + return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${EXPONENT_DELIMITER}${exponent}`; }; diff --git a/packages/core/src/systems/en-US/short-count/parse.ts b/packages/core/src/systems/en-US/short-count/parse.ts index e3d496a..aaf56bf 100644 --- a/packages/core/src/systems/en-US/short-count/parse.ts +++ b/packages/core/src/systems/en-US/short-count/parse.ts @@ -16,7 +16,7 @@ import { ILLION_SUFFIX, MILLIA_PREFIX, MILLIONS_PREFIXES, - MILLIONS_SPECIAL_PREFIXES, + MILLIONS_SPECIAL_PREFIXES, NEGATIVE, NEGATIVE_SYMBOL, ONES, OnesName, @@ -47,7 +47,7 @@ export const tokenize = (value: string) => ( .replace(/\n+/gs, ' ') .replace(/\s+/g, ' ') .replace( - new RegExp(`${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(\\d+)${SHORT_MILLIA_ILLION_DELIMITER}`, 'g'), + new RegExp(`${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(.+?)${SHORT_MILLIA_ILLION_DELIMITER}`, 'g'), (_substring, milliaCount: string) => `${MILLIA_PREFIX}${SHORT_MILLIA_DELIMITER}${milliaCount}`, ) .replace(new RegExp(`${TENS_ONES_SEPARATOR}`, 'g'), ' ') @@ -66,9 +66,10 @@ interface DoParseState { * Deconstructs a group name token (e.g. "million", "duodecillion", etc.) to its affixes and * parses them. * @param result - The current state of the parser. + * @param originalGroupName - The original group name token. * @returns DoParseState The next state of the parser. */ -const doParseGroupName = (result: DoParseState): DoParseState => { +const doParseGroupName = (result: DoParseState, originalGroupName: string): DoParseState => { if ( result.groupNameCurrent.length < 1 @@ -151,10 +152,13 @@ const doParseGroupName = (result: DoParseState): DoParseState => { const matchedMilliaArray = result.groupNameCurrent .match(new RegExp(`^${MILLIA_PREFIX}\\${SHORT_MILLIA_DELIMITER}(\\d+)`)); if (!matchedMilliaArray) { - throw new InvalidTokenError(result.groupNameCurrent); + throw new InvalidTokenError(originalGroupName); } const [wholeMilliaPrefix, matchedMillia] = matchedMilliaArray; newMillia = Number(matchedMillia); + if (newMillia < 1) { + throw new InvalidTokenError(originalGroupName); + } prefix = wholeMilliaPrefix; } else { newMillia = result.milliaIndex + 1; @@ -172,7 +176,7 @@ const doParseGroupName = (result: DoParseState): DoParseState => { }; } - throw new InvalidTokenError(result.groupNameCurrent); + throw new InvalidTokenError(originalGroupName); }; /** @@ -200,7 +204,7 @@ const getGroupPlaceFromGroupName = (groupName: string) => { }; do { - result = doParseGroupName(result); + result = doParseGroupName(result, groupName); } while (!result.done); const bigGroupPlace = BigInt( @@ -254,6 +258,7 @@ interface ParserState { lastToken?: string; groups: Group[]; mode: ParseGroupsMode; + negative: boolean; } const parseThousand = (acc: ParserState, token: string): ParserState => { @@ -398,7 +403,7 @@ const parseTens = (acc: ParserState, token: string): ParserState => { export const parseGroups = (tokens: string[]) => { // We add a final token which is an empty string to parse whatever the last non-empty token is. const tokensToParse = [...tokens, FINAL_TOKEN]; - const { groups } = tokensToParse.reduce( + const { groups, negative } = tokensToParse.reduce( (acc, token) => { if (token === THOUSAND || token.endsWith(ILLION_SUFFIX)) { return parseThousand(acc, token); @@ -424,35 +429,41 @@ export const parseGroups = (tokens: string[]) => { return parseTens(acc, token); } - return { - ...acc, - lastToken: token, - }; + if (token === NEGATIVE) { + return { + ...acc, + negative: !acc.negative, + }; + } + + throw new InvalidTokenError(token); }, { lastToken: undefined, groups: [], mode: ParseGroupsMode.INITIAL, + negative: false, }, ); - return groups; + return { groups, negative }; }; /** * Combines groups into a string. * @param groups - The groups to combine. + * @param negative - Whether the number is negative. * @see {NumberNameSystem.splitIntoGroups} * @returns string The combined groups in exponential form. */ -export const combineGroups = (groups: Group[]) => { - const places = groups.map((g) => g[GROUP_PLACE_INDEX]); - if (places.length < 1) { +export const combineGroups = (groups: Group[], negative: boolean) => { + if (groups.length < 1) { return ''; } + const places = groups.map((g) => g[GROUP_PLACE_INDEX]); const maxPlace = bigIntMax(...places) as bigint; const minPlace = bigIntMin(...places) as bigint; - const firstGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === maxPlace) ?? [...EMPTY_PLACE]; + const firstGroup = groups.find((g) => g[GROUP_PLACE_INDEX] === maxPlace) as Group; const firstGroupPlace = firstGroup[GROUP_PLACE_INDEX]; const groupsSorted = []; for (let i = maxPlace; i >= minPlace; i = BigInt(i) - BigInt(1)) { @@ -480,7 +491,7 @@ export const combineGroups = (groups: Group[]) => { const significandInteger = digits.slice(0, 1); const significandFraction = digits.slice(1).replace(/0+$/, ''); if (significandFraction.length > 0) { - return `${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; + return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${DECIMAL_POINT}${significandFraction}${EXPONENT_DELIMITER}${exponent}`; } - return `${significandInteger}${EXPONENT_DELIMITER}${exponent}`; + return `${negative ? NEGATIVE_SYMBOL : ''}${significandInteger}${EXPONENT_DELIMITER}${exponent}`; }; diff --git a/packages/core/src/systems/en-US/short-count/stringify.ts b/packages/core/src/systems/en-US/short-count/stringify.ts index 19e2a08..86c8390 100644 --- a/packages/core/src/systems/en-US/short-count/stringify.ts +++ b/packages/core/src/systems/en-US/short-count/stringify.ts @@ -306,7 +306,7 @@ export const splitIntoGroups = (value: string): Group[] => { if (lastGroup[GROUP_PLACE_INDEX] === currentPlace) { const lastGroupDigits = lastGroup[0].split(''); lastGroupDigits[currentPlaceInGroup] = c; - return [...acc.slice(0, -1) ?? [], [ + return [...acc.slice(0, -1), [ lastGroupDigits.join(''), currentPlace, ]]; diff --git a/packages/core/test/systems/en-UK/long-count.test.ts b/packages/core/test/systems/en-UK/long-count.test.ts index 8d227a6..4cd2500 100644 --- a/packages/core/test/systems/en-UK/long-count.test.ts +++ b/packages/core/test/systems/en-UK/long-count.test.ts @@ -29,6 +29,35 @@ const doExpect = ( }; describe('British long count', () => { + it('returns empty string for empty input', () => { + expect(parse('')).toBe(''); + }); + + it('formats doubly negative numbers to be positive', () => { + expect(stringify('--1')).toBe('one'); + expect(stringify('----1')).toBe('one'); + }); + + describe('error handling', () => { + it('throws an error when given fully unparseable input', () => { + expect(() => parse('foo')).toThrow(); + }); + + it('throws an error when given partially unparseable input', () => { + expect(() => parse('one milliamilliafootillion')).toThrow(); + expect(() => parse('one millia^foo-tillion')).toThrow(); + expect(() => parse('one millia^0-tillion')).toThrow(); + }); + }); + + describe('options', () => { + it('formats one group per line', () => { + expect(stringify(1234567, { mergeTokensOptions: { oneGroupPerLine: true } })).toBe(`one million +two hundred thirty-four thousand +five hundred sixty-seven`); + }); + }); + describe('individual cases', () => { const longNumberValue1 = '123,456,789,012,345,678,901,234,567,890'; const longNumberStringified1 = [ diff --git a/packages/core/test/systems/en-UK/short-count.test.ts b/packages/core/test/systems/en-UK/short-count.test.ts index 2f1cb08..b331b0c 100644 --- a/packages/core/test/systems/en-UK/short-count.test.ts +++ b/packages/core/test/systems/en-UK/short-count.test.ts @@ -29,6 +29,35 @@ const doExpect = ( }; describe('British short count', () => { + it('returns empty string for empty input', () => { + expect(parse('')).toBe(''); + }); + + it('formats doubly negative numbers to be positive', () => { + expect(stringify('--1')).toBe('one'); + expect(stringify('----1')).toBe('one'); + }); + + describe('error handling', () => { + it('throws an error when given fully unparseable input', () => { + expect(() => parse('foo')).toThrow(); + }); + + it('throws an error when given partially unparseable input', () => { + expect(() => parse('one milliamilliafootillion')).toThrow(); + expect(() => parse('one millia^foo-tillion')).toThrow(); + expect(() => parse('one millia^0-tillion')).toThrow(); + }); + }); + + describe('options', () => { + it('formats one group per line', () => { + expect(stringify(1234567, { mergeTokensOptions: { oneGroupPerLine: true } })).toBe(`one million +two hundred thirty-four thousand +five hundred sixty-seven`); + }); + }); + describe('individual cases', () => { const longNumberValue1 = '123,456,789,012,345,678,901,234,567,890'; const longNumberStringified1 = [ @@ -48,10 +77,13 @@ describe('British short count', () => { it.each` value | stringified | parsedValue + ${-1000000} | ${'negative one million'} | ${-1000000} + ${-1000500} | ${'negative one million five hundred'} | ${-1000500} ${1000} | ${'one thousand'} | ${1000} ${10000} | ${'ten thousand'} | ${10000} ${12012} | ${'twelve thousand twelve'} | ${12012} ${12020} | ${'twelve thousand twenty'} | ${12020} + ${20000} | ${'twenty thousand'} | ${20000} ${100000} | ${'one hundred thousand'} | ${100000} ${123456} | ${'one hundred twenty-three thousand four hundred fifty-six'} | ${123456} ${'1000005000000'} | ${'one trillion five million'} | ${'1.000005e+12'} diff --git a/packages/core/test/systems/en-US/short-count.test.ts b/packages/core/test/systems/en-US/short-count.test.ts index 641769e..4c96d9f 100644 --- a/packages/core/test/systems/en-US/short-count.test.ts +++ b/packages/core/test/systems/en-US/short-count.test.ts @@ -29,6 +29,35 @@ const doExpect = ( }; describe('American short count', () => { + it('returns empty string for empty input', () => { + expect(parse('')).toBe(''); + }); + + it('formats doubly negative numbers to be positive', () => { + expect(stringify('--1')).toBe('one'); + expect(stringify('----1')).toBe('one'); + }); + + describe('error handling', () => { + it('throws an error when given fully unparseable input', () => { + expect(() => parse('foo')).toThrow(); + }); + + it('throws an error when given partially unparseable input', () => { + expect(() => parse('one milliamilliafootillion')).toThrow(); + expect(() => parse('one millia^foo-tillion')).toThrow(); + expect(() => parse('one millia^0-tillion')).toThrow(); + }); + }); + + describe('options', () => { + it('formats one group per line', () => { + expect(stringify(1234567, { mergeTokensOptions: { oneGroupPerLine: true } })).toBe(`one million +two hundred thirty-four thousand +five hundred sixty-seven`); + }); + }); + describe('individual cases', () => { const longNumberValue1 = '123,456,789,012,345,678,901,234,567,890'; const longNumberStringified1 = [ @@ -48,6 +77,8 @@ describe('American short count', () => { it.each` value | stringified | parsedValue + ${-1000000} | ${'negative one million'} | ${-1000000} + ${-1000500} | ${'negative one million five hundred'} | ${-1000500} ${1000} | ${'one thousand'} | ${1000} ${10000} | ${'ten thousand'} | ${10000} ${12012} | ${'twelve thousand twelve'} | ${12012}