diff --git a/README.md b/README.md index d752132..3c2ee7b 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,77 @@ function example(s: string) { } ``` +### Localization + +`ms` ships with built-in locales for **French** (`fr`), **Arabic** (`ar`), **German** (`de`), **Spanish** (`es`), and **Chinese** (`zh`). Pass a locale to `format` or `ms` via the `locale` option: + +```ts +import { ms, format, fr, de, zh } from 'ms'; + +// Short format +format(60000, { locale: fr }) // "1min" +format(3600000, { locale: de }) // "1Std" +format(86400000, { locale: zh }) // "1天" + +// Long format +ms(1000, { locale: fr, long: true }) // "1 seconde" +ms(10000, { locale: fr, long: true }) // "10 secondes" +ms(3600000, { locale: de, long: true }) // "1 Stunde" +ms(7200000, { locale: de, long: true }) // "2 Stunden" +ms(86400000, { locale: zh, long: true }) // "1 天" +``` + +> [!NOTE] +> Parsing (`ms('1s')`, `parse('1h')`) is always English-only regardless of locale. Only formatting is localized. + +#### Adding a Custom Locale + +Implement the `LocaleDefinition` interface exported from `ms` and pass it as `locale`: + +```ts +import { format, type LocaleDefinition } from 'ms'; + +const pt: LocaleDefinition = { + shortUnits: { + ms: 'ms', + s: 's', + m: 'min', + h: 'h', + d: 'd', + w: 'sem', + mo: 'mês', + y: 'ano', + }, + longUnits: { + millisecond: ['milissegundo', 'milissegundos'], + second: ['segundo', 'segundos'], + minute: ['minuto', 'minutos'], + hour: ['hora', 'horas'], + day: ['dia', 'dias'], + week: ['semana', 'semanas'], + month: ['mês', 'meses'], + year: ['ano', 'anos'], + }, + // Return true when the plural form should be used. + // Omit to use the English default: value >= unitMs * 1.5. + isPlural: (value) => value !== 1, +}; + +format(1000, { locale: pt, long: true }); // "1 segundo" +format(10000, { locale: pt, long: true }); // "10 segundos" +format(60000, { locale: pt }); // "1min" +``` + +The `LocaleDefinition` fields: + +| Field | Type | Description | +|---|---|---| +| `shortUnits` | `object` | Abbreviated unit labels appended directly after the number (e.g. `1min`, `2h`) | +| `longUnits` | `object` | `[singular, plural]` pair for each unit, used in long format | +| `isPlural` | `(value: number) => boolean` | Optional. Receives the rounded absolute display value; return `true` to use the plural form. Defaults to `value >= unitMs * 1.5` (English-style threshold). | + +For languages without grammatical number (like Chinese), set `isPlural: () => false` and use the same string for both entries in each `longUnits` pair. + ## Edge Runtime Support `ms` is compatible with the [Edge Runtime](https://edge-runtime.vercel.app/). It can be used inside environments like [Vercel Edge Functions](https://vercel.com/edge) as follows: diff --git a/src/index.ts b/src/index.ts index d50e3c7..2092924 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,13 +31,74 @@ export type StringValue = | `${number}${UnitAnyCase}` | `${number} ${UnitAnyCase}`; -interface Options { +export interface LocaleDefinition { + shortUnits: { + ms: string; + s: string; + m: string; + h: string; + d: string; + w: string; + mo: string; + y: string; + }; + longUnits: { + millisecond: [string, string]; + second: [string, string]; + minute: [string, string]; + hour: [string, string]; + day: [string, string]; + week: [string, string]; + month: [string, string]; + year: [string, string]; + }; + /** + * Receives rounded abs display value, returns true if plural form should be used. + * Defaults to English-style: `msAbs >= unit * 1.5`. + */ + isPlural?: (value: number) => boolean; +} + +export interface Options { /** * Set to `true` to use verbose formatting. Defaults to `false`. */ long?: boolean; + /** + * Locale definition for formatting. Defaults to English. + */ + locale?: LocaleDefinition; } +export const en: LocaleDefinition = { + shortUnits: { + ms: 'ms', + s: 's', + m: 'm', + h: 'h', + d: 'd', + w: 'w', + mo: 'mo', + y: 'y', + }, + longUnits: { + millisecond: ['ms', 'ms'], + second: ['second', 'seconds'], + minute: ['minute', 'minutes'], + hour: ['hour', 'hours'], + day: ['day', 'days'], + week: ['week', 'weeks'], + month: ['month', 'months'], + year: ['year', 'years'], + }, +}; + +export { ar } from './locales/ar'; +export { de } from './locales/de'; +export { es } from './locales/es'; +export { fr } from './locales/fr'; +export { zh } from './locales/zh'; + /** * Parse or format the given value. * @@ -160,59 +221,62 @@ export function parseStrict(value: StringValue): number { /** * Short format for `ms`. */ -function fmtShort(ms: number): StringValue { +function fmtShort(ms: number, locale: LocaleDefinition): string { const msAbs = Math.abs(ms); + const u = locale.shortUnits; if (msAbs >= y) { - return `${Math.round(ms / y)}y`; + return `${Math.round(ms / y)}${u.y}`; } if (msAbs >= mo) { - return `${Math.round(ms / mo)}mo`; + return `${Math.round(ms / mo)}${u.mo}`; } if (msAbs >= w) { - return `${Math.round(ms / w)}w`; + return `${Math.round(ms / w)}${u.w}`; } if (msAbs >= d) { - return `${Math.round(ms / d)}d`; + return `${Math.round(ms / d)}${u.d}`; } if (msAbs >= h) { - return `${Math.round(ms / h)}h`; + return `${Math.round(ms / h)}${u.h}`; } if (msAbs >= m) { - return `${Math.round(ms / m)}m`; + return `${Math.round(ms / m)}${u.m}`; } if (msAbs >= s) { - return `${Math.round(ms / s)}s`; + return `${Math.round(ms / s)}${u.s}`; } - return `${ms}ms`; + return `${ms}${u.ms}`; } /** * Long format for `ms`. */ -function fmtLong(ms: number): StringValue { +function fmtLong(ms: number, locale: LocaleDefinition): string { const msAbs = Math.abs(ms); + const { longUnits, isPlural } = locale; if (msAbs >= y) { - return plural(ms, msAbs, y, 'year'); + return fmtPlural(ms, msAbs, y, longUnits.year, isPlural); } if (msAbs >= mo) { - return plural(ms, msAbs, mo, 'month'); + return fmtPlural(ms, msAbs, mo, longUnits.month, isPlural); } if (msAbs >= w) { - return plural(ms, msAbs, w, 'week'); + return fmtPlural(ms, msAbs, w, longUnits.week, isPlural); } if (msAbs >= d) { - return plural(ms, msAbs, d, 'day'); + return fmtPlural(ms, msAbs, d, longUnits.day, isPlural); } if (msAbs >= h) { - return plural(ms, msAbs, h, 'hour'); + return fmtPlural(ms, msAbs, h, longUnits.hour, isPlural); } if (msAbs >= m) { - return plural(ms, msAbs, m, 'minute'); + return fmtPlural(ms, msAbs, m, longUnits.minute, isPlural); } if (msAbs >= s) { - return plural(ms, msAbs, s, 'second'); + return fmtPlural(ms, msAbs, s, longUnits.second, isPlural); } - return `${ms} ms`; + const shouldBePlural = isPlural ? isPlural(msAbs) : msAbs >= 1.5; + return `${ms} ${shouldBePlural ? longUnits.millisecond[1] : longUnits.millisecond[0]}`; } /** @@ -227,18 +291,22 @@ export function format(ms: number, options?: Options): string { throw new Error('Value provided to ms.format() must be of type number.'); } - return options?.long ? fmtLong(ms) : fmtShort(ms); + const locale = options?.locale ?? en; + return options?.long ? fmtLong(ms, locale) : fmtShort(ms, locale); } /** * Pluralization helper. */ -function plural( +function fmtPlural( ms: number, msAbs: number, n: number, - name: string, -): StringValue { - const isPlural = msAbs >= n * 1.5; - return `${Math.round(ms / n)} ${name}${isPlural ? 's' : ''}` as StringValue; + units: [string, string], + isPlural?: (value: number) => boolean, +): string { + const value = Math.round(ms / n); + const absValue = Math.abs(value); + const shouldBePlural = isPlural ? isPlural(absValue) : msAbs >= n * 1.5; + return `${value} ${shouldBePlural ? units[1] : units[0]}`; } diff --git a/src/locales/ar.parse-strict.test.ts b/src/locales/ar.parse-strict.test.ts new file mode 100644 index 0000000..6bc4e7f --- /dev/null +++ b/src/locales/ar.parse-strict.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from '@jest/globals'; +import { parseStrict } from '../index'; + +// parseStrict() is typed to only accept StringValue (English units). +// All Arabic locale output uses Arabic script — none are valid StringValue. + +describe('parseStrict — ar short format', () => { + it('should return NaN for all Arabic short units (type errors)', () => { + // @ts-expect-error — Arabic script, not a StringValue + expect(Number.isNaN(parseStrict('500مللي ث'))).toBe(true); + // @ts-expect-error — Arabic script, not a StringValue + expect(Number.isNaN(parseStrict('1ث'))).toBe(true); + // @ts-expect-error — Arabic script, not a StringValue + expect(Number.isNaN(parseStrict('1د'))).toBe(true); + // @ts-expect-error — Arabic script, not a StringValue + expect(Number.isNaN(parseStrict('1س'))).toBe(true); + // @ts-expect-error — Arabic script, not a StringValue + expect(Number.isNaN(parseStrict('1ي'))).toBe(true); + // @ts-expect-error — Arabic script, not a StringValue + expect(Number.isNaN(parseStrict('1أ'))).toBe(true); + // @ts-expect-error — Arabic script, not a StringValue + expect(Number.isNaN(parseStrict('1شه'))).toBe(true); + // @ts-expect-error — Arabic script, not a StringValue + expect(Number.isNaN(parseStrict('1سن'))).toBe(true); + }); +}); + +describe('parseStrict — ar long format', () => { + it('should return NaN for all Arabic long unit words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 مللي ثانية'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 ثانية'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 دقيقة'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 ساعة'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 يوم'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 أسبوع'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 شهر'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 سنة'))).toBe(true); + }); +}); diff --git a/src/locales/ar.parse.test.ts b/src/locales/ar.parse.test.ts new file mode 100644 index 0000000..6a56fb1 --- /dev/null +++ b/src/locales/ar.parse.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from '@jest/globals'; +import { ar, format, parse } from '../index'; + +// parse() is English-only. All Arabic locale output uses Arabic script, +// so no locale-formatted strings can be round-tripped through parse(). + +describe('parse — ar short format', () => { + it('should return NaN for all Arabic short units', () => { + expect(Number.isNaN(parse(format(500, { locale: ar })))).toBe(true); // '500مللي ث' + expect(Number.isNaN(parse(format(1000, { locale: ar })))).toBe(true); // '1ث' + expect(Number.isNaN(parse(format(60 * 1000, { locale: ar })))).toBe(true); // '1د' + expect(Number.isNaN(parse(format(60 * 60 * 1000, { locale: ar })))).toBe( + true, + ); // '1س' + expect( + Number.isNaN(parse(format(24 * 60 * 60 * 1000, { locale: ar }))), + ).toBe(true); // '1ي' + expect( + Number.isNaN(parse(format(7 * 24 * 60 * 60 * 1000, { locale: ar }))), + ).toBe(true); // '1أ' + expect( + Number.isNaN( + parse(format(30.4375 * 24 * 60 * 60 * 1000, { locale: ar })), + ), + ).toBe(true); // '1شه' + expect( + Number.isNaN( + parse(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: ar })), + ), + ).toBe(true); // '1سن' + }); +}); + +describe('parse — ar long format', () => { + it('should return NaN for all Arabic long unit words', () => { + expect(Number.isNaN(parse('1 مللي ثانية'))).toBe(true); + expect(Number.isNaN(parse('1 ثانية'))).toBe(true); + expect(Number.isNaN(parse('1 دقيقة'))).toBe(true); + expect(Number.isNaN(parse('1 ساعة'))).toBe(true); + expect(Number.isNaN(parse('1 يوم'))).toBe(true); + expect(Number.isNaN(parse('1 أسبوع'))).toBe(true); + expect(Number.isNaN(parse('1 شهر'))).toBe(true); + expect(Number.isNaN(parse('1 سنة'))).toBe(true); + }); +}); diff --git a/src/locales/ar.test.ts b/src/locales/ar.test.ts new file mode 100644 index 0000000..a050b23 --- /dev/null +++ b/src/locales/ar.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from '@jest/globals'; +import { ar, format } from '../index'; + +describe('format(number, { locale: ar, long: true })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: ar, long: true }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(1, { locale: ar, long: true })).toBe('1 مللي ثانية'); + expect(format(500, { locale: ar, long: true })).toBe('500 مللي ثانية'); + + expect(format(-1, { locale: ar, long: true })).toBe('-1 مللي ثانية'); + expect(format(-500, { locale: ar, long: true })).toBe('-500 مللي ثانية'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: ar, long: true })).toBe('1 ثانية'); + expect(format(1200, { locale: ar, long: true })).toBe('1 ثانية'); + expect(format(10000, { locale: ar, long: true })).toBe('10 ثوانٍ'); + + expect(format(-1000, { locale: ar, long: true })).toBe('-1 ثانية'); + expect(format(-1200, { locale: ar, long: true })).toBe('-1 ثانية'); + expect(format(-10000, { locale: ar, long: true })).toBe('-10 ثوانٍ'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: ar, long: true })).toBe('1 دقيقة'); + expect(format(60 * 1200, { locale: ar, long: true })).toBe('1 دقيقة'); + expect(format(60 * 10000, { locale: ar, long: true })).toBe('10 دقائق'); + + expect(format(-1 * 60 * 1000, { locale: ar, long: true })).toBe('-1 دقيقة'); + expect(format(-1 * 60 * 1200, { locale: ar, long: true })).toBe('-1 دقيقة'); + expect(format(-1 * 60 * 10000, { locale: ar, long: true })).toBe( + '-10 دقائق', + ); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: ar, long: true })).toBe('1 ساعة'); + expect(format(60 * 60 * 1200, { locale: ar, long: true })).toBe('1 ساعة'); + expect(format(60 * 60 * 10000, { locale: ar, long: true })).toBe( + '10 ساعات', + ); + + expect(format(-1 * 60 * 60 * 1000, { locale: ar, long: true })).toBe( + '-1 ساعة', + ); + expect(format(-1 * 60 * 60 * 1200, { locale: ar, long: true })).toBe( + '-1 ساعة', + ); + expect(format(-1 * 60 * 60 * 10000, { locale: ar, long: true })).toBe( + '-10 ساعات', + ); + }); + + it('should support days', () => { + expect(format(1 * 24 * 60 * 60 * 1000, { locale: ar, long: true })).toBe( + '1 يوم', + ); + expect(format(1 * 24 * 60 * 60 * 1200, { locale: ar, long: true })).toBe( + '1 يوم', + ); + expect(format(6 * 24 * 60 * 60 * 1000, { locale: ar, long: true })).toBe( + '6 أيام', + ); + + expect( + format(-1 * 1 * 24 * 60 * 60 * 1000, { locale: ar, long: true }), + ).toBe('-1 يوم'); + expect( + format(-1 * 1 * 24 * 60 * 60 * 1200, { locale: ar, long: true }), + ).toBe('-1 يوم'); + expect( + format(-1 * 6 * 24 * 60 * 60 * 1000, { locale: ar, long: true }), + ).toBe('-6 أيام'); + }); + + it('should support weeks', () => { + expect( + format(1 * 7 * 24 * 60 * 60 * 1000, { locale: ar, long: true }), + ).toBe('1 أسبوع'); + expect( + format(2 * 7 * 24 * 60 * 60 * 1000, { locale: ar, long: true }), + ).toBe('2 أسابيع'); + + expect( + format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: ar, long: true }), + ).toBe('-1 أسبوع'); + expect( + format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: ar, long: true }), + ).toBe('-2 أسابيع'); + }); + + it('should support months', () => { + expect( + format(30.4375 * 24 * 60 * 60 * 1000, { locale: ar, long: true }), + ).toBe('1 شهر'); + expect( + format(30.4375 * 24 * 60 * 60 * 1200, { locale: ar, long: true }), + ).toBe('1 شهر'); + expect( + format(30.4375 * 24 * 60 * 60 * 10000, { locale: ar, long: true }), + ).toBe('10 أشهر'); + + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: ar, long: true }), + ).toBe('-1 شهر'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: ar, long: true }), + ).toBe('-1 شهر'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: ar, long: true }), + ).toBe('-10 أشهر'); + }); + + it('should support years', () => { + expect( + format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: ar, long: true }), + ).toBe('1 سنة'); + expect( + format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: ar, long: true }), + ).toBe('1 سنة'); + expect( + format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: ar, long: true }), + ).toBe('10 سنوات'); + + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { + locale: ar, + long: true, + }), + ).toBe('-1 سنة'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { + locale: ar, + long: true, + }), + ).toBe('-1 سنة'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { + locale: ar, + long: true, + }), + ).toBe('-10 سنوات'); + }); + + it('should round', () => { + expect(format(234234234, { locale: ar, long: true })).toBe('3 أيام'); + + expect(format(-234234234, { locale: ar, long: true })).toBe('-3 أيام'); + }); +}); + +describe('format(number, { locale: ar })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: ar }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(500, { locale: ar })).toBe('500مللي ث'); + + expect(format(-500, { locale: ar })).toBe('-500مللي ث'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: ar })).toBe('1ث'); + expect(format(10000, { locale: ar })).toBe('10ث'); + + expect(format(-1000, { locale: ar })).toBe('-1ث'); + expect(format(-10000, { locale: ar })).toBe('-10ث'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: ar })).toBe('1د'); + expect(format(60 * 10000, { locale: ar })).toBe('10د'); + + expect(format(-1 * 60 * 1000, { locale: ar })).toBe('-1د'); + expect(format(-1 * 60 * 10000, { locale: ar })).toBe('-10د'); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: ar })).toBe('1س'); + expect(format(60 * 60 * 10000, { locale: ar })).toBe('10س'); + + expect(format(-1 * 60 * 60 * 1000, { locale: ar })).toBe('-1س'); + expect(format(-1 * 60 * 60 * 10000, { locale: ar })).toBe('-10س'); + }); + + it('should support days', () => { + expect(format(24 * 60 * 60 * 1000, { locale: ar })).toBe('1ي'); + expect(format(24 * 60 * 60 * 6000, { locale: ar })).toBe('6ي'); + + expect(format(-1 * 24 * 60 * 60 * 1000, { locale: ar })).toBe('-1ي'); + expect(format(-1 * 24 * 60 * 60 * 6000, { locale: ar })).toBe('-6ي'); + }); + + it('should support weeks', () => { + expect(format(1 * 7 * 24 * 60 * 60 * 1000, { locale: ar })).toBe('1أ'); + expect(format(2 * 7 * 24 * 60 * 60 * 1000, { locale: ar })).toBe('2أ'); + + expect(format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: ar })).toBe( + '-1أ', + ); + expect(format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: ar })).toBe( + '-2أ', + ); + }); + + it('should support months', () => { + expect(format(30.4375 * 24 * 60 * 60 * 1000, { locale: ar })).toBe('1شه'); + expect(format(30.4375 * 24 * 60 * 60 * 1200, { locale: ar })).toBe('1شه'); + expect(format(30.4375 * 24 * 60 * 60 * 10000, { locale: ar })).toBe('10شه'); + + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: ar })).toBe( + '-1شه', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: ar })).toBe( + '-1شه', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: ar })).toBe( + '-10شه', + ); + }); + + it('should support years', () => { + expect(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: ar })).toBe( + '1سن', + ); + expect(format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: ar })).toBe( + '1سن', + ); + expect(format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: ar })).toBe( + '10سن', + ); + + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { locale: ar })).toBe( + '-1سن', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { locale: ar })).toBe( + '-1سن', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { locale: ar })).toBe( + '-10سن', + ); + }); + + it('should round', () => { + expect(format(234234234, { locale: ar })).toBe('3ي'); + + expect(format(-234234234, { locale: ar })).toBe('-3ي'); + }); +}); diff --git a/src/locales/ar.ts b/src/locales/ar.ts new file mode 100644 index 0000000..b4bc0ae --- /dev/null +++ b/src/locales/ar.ts @@ -0,0 +1,25 @@ +import type { LocaleDefinition } from '../index'; + +export const ar: LocaleDefinition = { + shortUnits: { + ms: 'مللي ث', + s: 'ث', + m: 'د', + h: 'س', + d: 'ي', + w: 'أ', + mo: 'شه', + y: 'سن', + }, + longUnits: { + millisecond: ['مللي ثانية', 'مللي ثانية'], + second: ['ثانية', 'ثوانٍ'], + minute: ['دقيقة', 'دقائق'], + hour: ['ساعة', 'ساعات'], + day: ['يوم', 'أيام'], + week: ['أسبوع', 'أسابيع'], + month: ['شهر', 'أشهر'], + year: ['سنة', 'سنوات'], + }, + isPlural: (v) => v !== 1, +}; diff --git a/src/locales/de.parse-strict.test.ts b/src/locales/de.parse-strict.test.ts new file mode 100644 index 0000000..46022d3 --- /dev/null +++ b/src/locales/de.parse-strict.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from '@jest/globals'; +import { parseStrict } from '../index'; + +// parseStrict() is typed to only accept StringValue (English units). +// Note: "W" is Capitalize<"w"> and "Mo" is Capitalize<"mo">, both valid StringValue. + +describe('parseStrict — de short format', () => { + it('should round-trip milliseconds (ms = "ms")', () => { + expect(parseStrict('500ms')).toBe(500); + expect(parseStrict('-500ms')).toBe(-500); + }); + + it('should round-trip seconds (s = "s")', () => { + expect(parseStrict('1s')).toBe(1000); + expect(parseStrict('10s')).toBe(10000); + }); + + it('should round-trip minutes (m = "min")', () => { + expect(parseStrict('1min')).toBe(60 * 1000); + expect(parseStrict('10min')).toBe(60 * 10000); + }); + + it('should return NaN for hours (h = "Std" — type error: not a StringValue)', () => { + // @ts-expect-error — '1Std' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1Std'))).toBe(true); + }); + + it('should return NaN for days (d = "T" — type error: not a StringValue)', () => { + // @ts-expect-error — '1T' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1T'))).toBe(true); + }); + + it('should round-trip weeks (w = "W" — valid StringValue: Capitalize<"w">)', () => { + expect(parseStrict('1W')).toBe(7 * 24 * 60 * 60 * 1000); + expect(parseStrict('2W')).toBe(2 * 7 * 24 * 60 * 60 * 1000); + }); + + it('should round-trip months (mo = "Mo" — valid StringValue: Capitalize<"mo">)', () => { + expect(parseStrict('1Mo')).toBe(30.4375 * 24 * 60 * 60 * 1000); + }); + + it('should return NaN for years (y = "J" — type error: not a StringValue)', () => { + // @ts-expect-error — '1J' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1J'))).toBe(true); + }); +}); + +describe('parseStrict — de long format', () => { + it('should return NaN for German millisecond words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 Millisekunde'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('500 Millisekunden'))).toBe(true); + }); + + it('should return NaN for German second words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 Sekunde'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('10 Sekunden'))).toBe(true); + }); + + it('should parse "Minute" (valid StringValue: Capitalize<"minute">)', () => { + expect(parseStrict('1 Minute')).toBe(60 * 1000); + }); + + it('should return NaN for "Minuten" (type error: not a StringValue)', () => { + // @ts-expect-error — 'Minuten' is not in UnitAnyCase + expect(Number.isNaN(parseStrict('10 Minuten'))).toBe(true); + }); + + it('should return NaN for German hour, day, week, month, year words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 Stunde'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 Tag'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 Woche'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 Monat'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 Jahr'))).toBe(true); + }); +}); diff --git a/src/locales/de.parse.test.ts b/src/locales/de.parse.test.ts new file mode 100644 index 0000000..225f8cb --- /dev/null +++ b/src/locales/de.parse.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from '@jest/globals'; +import { de, format, parse } from '../index'; + +// parse() is English-only. These tests document which strings produced by +// format() with the German locale can be round-tripped through parse(). +// Note: the English regex is case-insensitive, so "W" matches "w" (week) +// and "Mo" matches "mo" (month). + +describe('parse — de short format', () => { + it('should round-trip milliseconds (ms = "ms")', () => { + expect(parse(format(500, { locale: de }))).toBe(500); // '500ms' + expect(parse(format(-500, { locale: de }))).toBe(-500); // '-500ms' + }); + + it('should round-trip seconds (s = "s")', () => { + expect(parse(format(1000, { locale: de }))).toBe(1000); // '1s' + expect(parse(format(10000, { locale: de }))).toBe(10000); // '10s' + }); + + it('should round-trip minutes (m = "min")', () => { + expect(parse(format(60 * 1000, { locale: de }))).toBe(60 * 1000); // '1min' + expect(parse(format(60 * 10000, { locale: de }))).toBe(60 * 10000); // '10min' + }); + + it('should return NaN for hours (h = "Std" — not an English unit)', () => { + expect(Number.isNaN(parse(format(60 * 60 * 1000, { locale: de })))).toBe( + true, + ); // '1Std' + }); + + it('should return NaN for days (d = "T" — not an English unit)', () => { + expect( + Number.isNaN(parse(format(24 * 60 * 60 * 1000, { locale: de }))), + ).toBe(true); // '1T' + }); + + it('should round-trip weeks (w = "W" — case-insensitively matches English "w")', () => { + expect(parse(format(7 * 24 * 60 * 60 * 1000, { locale: de }))).toBe( + 7 * 24 * 60 * 60 * 1000, + ); // '1W' + expect(parse(format(2 * 7 * 24 * 60 * 60 * 1000, { locale: de }))).toBe( + 2 * 7 * 24 * 60 * 60 * 1000, + ); // '2W' + }); + + it('should round-trip months (mo = "Mo" — case-insensitively matches English "mo")', () => { + expect(parse(format(30.4375 * 24 * 60 * 60 * 1000, { locale: de }))).toBe( + 30.4375 * 24 * 60 * 60 * 1000, + ); // '1Mo' + }); + + it('should return NaN for years (y = "J" — not an English unit)', () => { + expect( + Number.isNaN( + parse(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: de })), + ), + ).toBe(true); // '1J' + }); +}); + +describe('parse — de long format', () => { + it('should return NaN for German millisecond words', () => { + expect(Number.isNaN(parse('1 Millisekunde'))).toBe(true); + expect(Number.isNaN(parse('500 Millisekunden'))).toBe(true); + }); + + it('should return NaN for German second words', () => { + expect(Number.isNaN(parse('1 Sekunde'))).toBe(true); + expect(Number.isNaN(parse('10 Sekunden'))).toBe(true); + }); + + it('should parse "Minute" (case-insensitively matches English "minute")', () => { + expect(parse('1 Minute')).toBe(60 * 1000); + }); + + it('should return NaN for "Minuten" (German plural — no English match)', () => { + expect(Number.isNaN(parse('10 Minuten'))).toBe(true); + }); + + it('should return NaN for German hour, day, week, month, year words', () => { + expect(Number.isNaN(parse('1 Stunde'))).toBe(true); + expect(Number.isNaN(parse('1 Tag'))).toBe(true); + expect(Number.isNaN(parse('1 Woche'))).toBe(true); + expect(Number.isNaN(parse('1 Monat'))).toBe(true); + expect(Number.isNaN(parse('1 Jahr'))).toBe(true); + }); +}); diff --git a/src/locales/de.test.ts b/src/locales/de.test.ts new file mode 100644 index 0000000..0897973 --- /dev/null +++ b/src/locales/de.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from '@jest/globals'; +import { de, format } from '../index'; + +describe('format(number, { locale: de, long: true })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: de, long: true }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(1, { locale: de, long: true })).toBe('1 Millisekunde'); + expect(format(500, { locale: de, long: true })).toBe('500 Millisekunden'); + + expect(format(-1, { locale: de, long: true })).toBe('-1 Millisekunde'); + expect(format(-500, { locale: de, long: true })).toBe('-500 Millisekunden'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: de, long: true })).toBe('1 Sekunde'); + expect(format(1200, { locale: de, long: true })).toBe('1 Sekunde'); + expect(format(10000, { locale: de, long: true })).toBe('10 Sekunden'); + + expect(format(-1000, { locale: de, long: true })).toBe('-1 Sekunde'); + expect(format(-1200, { locale: de, long: true })).toBe('-1 Sekunde'); + expect(format(-10000, { locale: de, long: true })).toBe('-10 Sekunden'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: de, long: true })).toBe('1 Minute'); + expect(format(60 * 1200, { locale: de, long: true })).toBe('1 Minute'); + expect(format(60 * 10000, { locale: de, long: true })).toBe('10 Minuten'); + + expect(format(-1 * 60 * 1000, { locale: de, long: true })).toBe( + '-1 Minute', + ); + expect(format(-1 * 60 * 1200, { locale: de, long: true })).toBe( + '-1 Minute', + ); + expect(format(-1 * 60 * 10000, { locale: de, long: true })).toBe( + '-10 Minuten', + ); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: de, long: true })).toBe('1 Stunde'); + expect(format(60 * 60 * 1200, { locale: de, long: true })).toBe('1 Stunde'); + expect(format(60 * 60 * 10000, { locale: de, long: true })).toBe( + '10 Stunden', + ); + + expect(format(-1 * 60 * 60 * 1000, { locale: de, long: true })).toBe( + '-1 Stunde', + ); + expect(format(-1 * 60 * 60 * 1200, { locale: de, long: true })).toBe( + '-1 Stunde', + ); + expect(format(-1 * 60 * 60 * 10000, { locale: de, long: true })).toBe( + '-10 Stunden', + ); + }); + + it('should support days', () => { + expect(format(1 * 24 * 60 * 60 * 1000, { locale: de, long: true })).toBe( + '1 Tag', + ); + expect(format(1 * 24 * 60 * 60 * 1200, { locale: de, long: true })).toBe( + '1 Tag', + ); + expect(format(6 * 24 * 60 * 60 * 1000, { locale: de, long: true })).toBe( + '6 Tage', + ); + + expect( + format(-1 * 1 * 24 * 60 * 60 * 1000, { locale: de, long: true }), + ).toBe('-1 Tag'); + expect( + format(-1 * 1 * 24 * 60 * 60 * 1200, { locale: de, long: true }), + ).toBe('-1 Tag'); + expect( + format(-1 * 6 * 24 * 60 * 60 * 1000, { locale: de, long: true }), + ).toBe('-6 Tage'); + }); + + it('should support weeks', () => { + expect( + format(1 * 7 * 24 * 60 * 60 * 1000, { locale: de, long: true }), + ).toBe('1 Woche'); + expect( + format(2 * 7 * 24 * 60 * 60 * 1000, { locale: de, long: true }), + ).toBe('2 Wochen'); + + expect( + format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: de, long: true }), + ).toBe('-1 Woche'); + expect( + format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: de, long: true }), + ).toBe('-2 Wochen'); + }); + + it('should support months', () => { + expect( + format(30.4375 * 24 * 60 * 60 * 1000, { locale: de, long: true }), + ).toBe('1 Monat'); + expect( + format(30.4375 * 24 * 60 * 60 * 1200, { locale: de, long: true }), + ).toBe('1 Monat'); + expect( + format(30.4375 * 24 * 60 * 60 * 10000, { locale: de, long: true }), + ).toBe('10 Monate'); + + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: de, long: true }), + ).toBe('-1 Monat'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: de, long: true }), + ).toBe('-1 Monat'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: de, long: true }), + ).toBe('-10 Monate'); + }); + + it('should support years', () => { + expect( + format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: de, long: true }), + ).toBe('1 Jahr'); + expect( + format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: de, long: true }), + ).toBe('1 Jahr'); + expect( + format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: de, long: true }), + ).toBe('10 Jahre'); + + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { + locale: de, + long: true, + }), + ).toBe('-1 Jahr'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { + locale: de, + long: true, + }), + ).toBe('-1 Jahr'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { + locale: de, + long: true, + }), + ).toBe('-10 Jahre'); + }); + + it('should round', () => { + expect(format(234234234, { locale: de, long: true })).toBe('3 Tage'); + + expect(format(-234234234, { locale: de, long: true })).toBe('-3 Tage'); + }); +}); + +describe('format(number, { locale: de })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: de }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(500, { locale: de })).toBe('500ms'); + + expect(format(-500, { locale: de })).toBe('-500ms'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: de })).toBe('1s'); + expect(format(10000, { locale: de })).toBe('10s'); + + expect(format(-1000, { locale: de })).toBe('-1s'); + expect(format(-10000, { locale: de })).toBe('-10s'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: de })).toBe('1min'); + expect(format(60 * 10000, { locale: de })).toBe('10min'); + + expect(format(-1 * 60 * 1000, { locale: de })).toBe('-1min'); + expect(format(-1 * 60 * 10000, { locale: de })).toBe('-10min'); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: de })).toBe('1Std'); + expect(format(60 * 60 * 10000, { locale: de })).toBe('10Std'); + + expect(format(-1 * 60 * 60 * 1000, { locale: de })).toBe('-1Std'); + expect(format(-1 * 60 * 60 * 10000, { locale: de })).toBe('-10Std'); + }); + + it('should support days', () => { + expect(format(24 * 60 * 60 * 1000, { locale: de })).toBe('1T'); + expect(format(24 * 60 * 60 * 6000, { locale: de })).toBe('6T'); + + expect(format(-1 * 24 * 60 * 60 * 1000, { locale: de })).toBe('-1T'); + expect(format(-1 * 24 * 60 * 60 * 6000, { locale: de })).toBe('-6T'); + }); + + it('should support weeks', () => { + expect(format(1 * 7 * 24 * 60 * 60 * 1000, { locale: de })).toBe('1W'); + expect(format(2 * 7 * 24 * 60 * 60 * 1000, { locale: de })).toBe('2W'); + + expect(format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: de })).toBe( + '-1W', + ); + expect(format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: de })).toBe( + '-2W', + ); + }); + + it('should support months', () => { + expect(format(30.4375 * 24 * 60 * 60 * 1000, { locale: de })).toBe('1Mo'); + expect(format(30.4375 * 24 * 60 * 60 * 1200, { locale: de })).toBe('1Mo'); + expect(format(30.4375 * 24 * 60 * 60 * 10000, { locale: de })).toBe('10Mo'); + + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: de })).toBe( + '-1Mo', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: de })).toBe( + '-1Mo', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: de })).toBe( + '-10Mo', + ); + }); + + it('should support years', () => { + expect(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: de })).toBe('1J'); + expect(format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: de })).toBe('1J'); + expect(format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: de })).toBe( + '10J', + ); + + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { locale: de })).toBe( + '-1J', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { locale: de })).toBe( + '-1J', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { locale: de })).toBe( + '-10J', + ); + }); + + it('should round', () => { + expect(format(234234234, { locale: de })).toBe('3T'); + + expect(format(-234234234, { locale: de })).toBe('-3T'); + }); +}); diff --git a/src/locales/de.ts b/src/locales/de.ts new file mode 100644 index 0000000..751a435 --- /dev/null +++ b/src/locales/de.ts @@ -0,0 +1,25 @@ +import type { LocaleDefinition } from '../index'; + +export const de: LocaleDefinition = { + shortUnits: { + ms: 'ms', + s: 's', + m: 'min', + h: 'Std', + d: 'T', + w: 'W', + mo: 'Mo', + y: 'J', + }, + longUnits: { + millisecond: ['Millisekunde', 'Millisekunden'], + second: ['Sekunde', 'Sekunden'], + minute: ['Minute', 'Minuten'], + hour: ['Stunde', 'Stunden'], + day: ['Tag', 'Tage'], + week: ['Woche', 'Wochen'], + month: ['Monat', 'Monate'], + year: ['Jahr', 'Jahre'], + }, + isPlural: (v) => v !== 1, +}; diff --git a/src/locales/es.parse-strict.test.ts b/src/locales/es.parse-strict.test.ts new file mode 100644 index 0000000..98e7a96 --- /dev/null +++ b/src/locales/es.parse-strict.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from '@jest/globals'; +import { parseStrict } from '../index'; + +// parseStrict() is typed to only accept StringValue (English units). +// Locale-specific strings that fall outside StringValue are marked @ts-expect-error. + +describe('parseStrict — es short format', () => { + it('should round-trip milliseconds (ms = "ms")', () => { + expect(parseStrict('500ms')).toBe(500); + expect(parseStrict('-500ms')).toBe(-500); + }); + + it('should round-trip seconds (s = "s")', () => { + expect(parseStrict('1s')).toBe(1000); + expect(parseStrict('10s')).toBe(10000); + }); + + it('should round-trip minutes (m = "min")', () => { + expect(parseStrict('1min')).toBe(60 * 1000); + expect(parseStrict('10min')).toBe(60 * 10000); + }); + + it('should round-trip hours (h = "h")', () => { + expect(parseStrict('1h')).toBe(60 * 60 * 1000); + expect(parseStrict('10h')).toBe(60 * 60 * 10000); + }); + + it('should round-trip days (d = "d" — valid StringValue)', () => { + expect(parseStrict('1d')).toBe(24 * 60 * 60 * 1000); + expect(parseStrict('6d')).toBe(6 * 24 * 60 * 60 * 1000); + }); + + it('should return NaN for weeks (w = "sem" — type error: not a StringValue)', () => { + // @ts-expect-error — '1sem' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1sem'))).toBe(true); + }); + + it('should return NaN for months (mo = "mes" — type error: not a StringValue)', () => { + // @ts-expect-error — '1mes' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1mes'))).toBe(true); + }); + + it('should return NaN for years (y = "año" — type error: not a StringValue)', () => { + // @ts-expect-error — '1año' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1año'))).toBe(true); + }); +}); + +describe('parseStrict — es long format', () => { + it('should return NaN for Spanish millisecond words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 milisegundo'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('10 milisegundos'))).toBe(true); + }); + + it('should return NaN for Spanish second words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 segundo'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('10 segundos'))).toBe(true); + }); + + it('should return NaN for Spanish minute words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 minuto'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('10 minutos'))).toBe(true); + }); + + it('should return NaN for Spanish hour, day, week, month, year words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 hora'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 día'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 semana'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 mes'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 año'))).toBe(true); + }); +}); diff --git a/src/locales/es.parse.test.ts b/src/locales/es.parse.test.ts new file mode 100644 index 0000000..676bc92 --- /dev/null +++ b/src/locales/es.parse.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@jest/globals'; +import { es, format, parse } from '../index'; + +// parse() is English-only. These tests document which strings produced by +// format() with the Spanish locale can be round-tripped through parse(). + +describe('parse — es short format', () => { + it('should round-trip milliseconds (ms = "ms")', () => { + expect(parse(format(500, { locale: es }))).toBe(500); // '500ms' + expect(parse(format(-500, { locale: es }))).toBe(-500); // '-500ms' + }); + + it('should round-trip seconds (s = "s")', () => { + expect(parse(format(1000, { locale: es }))).toBe(1000); // '1s' + expect(parse(format(10000, { locale: es }))).toBe(10000); // '10s' + }); + + it('should round-trip minutes (m = "min")', () => { + expect(parse(format(60 * 1000, { locale: es }))).toBe(60 * 1000); // '1min' + expect(parse(format(60 * 10000, { locale: es }))).toBe(60 * 10000); // '10min' + }); + + it('should round-trip hours (h = "h")', () => { + expect(parse(format(60 * 60 * 1000, { locale: es }))).toBe(60 * 60 * 1000); // '1h' + expect(parse(format(60 * 60 * 10000, { locale: es }))).toBe( + 60 * 60 * 10000, + ); // '10h' + }); + + it('should round-trip days (d = "d" — same as English)', () => { + expect(parse(format(24 * 60 * 60 * 1000, { locale: es }))).toBe( + 24 * 60 * 60 * 1000, + ); // '1d' + expect(parse(format(6 * 24 * 60 * 60 * 1000, { locale: es }))).toBe( + 6 * 24 * 60 * 60 * 1000, + ); // '6d' + }); + + it('should return NaN for weeks (w = "sem" — not an English unit)', () => { + expect( + Number.isNaN(parse(format(7 * 24 * 60 * 60 * 1000, { locale: es }))), + ).toBe(true); // '1sem' + }); + + it('should return NaN for months (mo = "mes" — not an English unit)', () => { + expect( + Number.isNaN( + parse(format(30.4375 * 24 * 60 * 60 * 1000, { locale: es })), + ), + ).toBe(true); // '1mes' + }); + + it('should return NaN for years (y = "año" — not an English unit)', () => { + expect( + Number.isNaN( + parse(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: es })), + ), + ).toBe(true); // '1año' + }); +}); + +describe('parse — es long format', () => { + it('should return NaN for Spanish millisecond words', () => { + expect(Number.isNaN(parse('1 milisegundo'))).toBe(true); + expect(Number.isNaN(parse('10 milisegundos'))).toBe(true); + }); + + it('should return NaN for Spanish second words', () => { + expect(Number.isNaN(parse('1 segundo'))).toBe(true); + expect(Number.isNaN(parse('10 segundos'))).toBe(true); + }); + + it('should return NaN for Spanish minute words', () => { + expect(Number.isNaN(parse('1 minuto'))).toBe(true); + expect(Number.isNaN(parse('10 minutos'))).toBe(true); + }); + + it('should return NaN for Spanish hour, day, week, month, year words', () => { + expect(Number.isNaN(parse('1 hora'))).toBe(true); + expect(Number.isNaN(parse('1 día'))).toBe(true); + expect(Number.isNaN(parse('1 semana'))).toBe(true); + expect(Number.isNaN(parse('1 mes'))).toBe(true); + expect(Number.isNaN(parse('1 año'))).toBe(true); + }); +}); diff --git a/src/locales/es.test.ts b/src/locales/es.test.ts new file mode 100644 index 0000000..a54554c --- /dev/null +++ b/src/locales/es.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from '@jest/globals'; +import { es, format } from '../index'; + +describe('format(number, { locale: es, long: true })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: es, long: true }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(1, { locale: es, long: true })).toBe('1 milisegundo'); + expect(format(500, { locale: es, long: true })).toBe('500 milisegundos'); + + expect(format(-1, { locale: es, long: true })).toBe('-1 milisegundo'); + expect(format(-500, { locale: es, long: true })).toBe('-500 milisegundos'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: es, long: true })).toBe('1 segundo'); + expect(format(1200, { locale: es, long: true })).toBe('1 segundo'); + expect(format(10000, { locale: es, long: true })).toBe('10 segundos'); + + expect(format(-1000, { locale: es, long: true })).toBe('-1 segundo'); + expect(format(-1200, { locale: es, long: true })).toBe('-1 segundo'); + expect(format(-10000, { locale: es, long: true })).toBe('-10 segundos'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: es, long: true })).toBe('1 minuto'); + expect(format(60 * 1200, { locale: es, long: true })).toBe('1 minuto'); + expect(format(60 * 10000, { locale: es, long: true })).toBe('10 minutos'); + + expect(format(-1 * 60 * 1000, { locale: es, long: true })).toBe( + '-1 minuto', + ); + expect(format(-1 * 60 * 1200, { locale: es, long: true })).toBe( + '-1 minuto', + ); + expect(format(-1 * 60 * 10000, { locale: es, long: true })).toBe( + '-10 minutos', + ); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: es, long: true })).toBe('1 hora'); + expect(format(60 * 60 * 1200, { locale: es, long: true })).toBe('1 hora'); + expect(format(60 * 60 * 10000, { locale: es, long: true })).toBe( + '10 horas', + ); + + expect(format(-1 * 60 * 60 * 1000, { locale: es, long: true })).toBe( + '-1 hora', + ); + expect(format(-1 * 60 * 60 * 1200, { locale: es, long: true })).toBe( + '-1 hora', + ); + expect(format(-1 * 60 * 60 * 10000, { locale: es, long: true })).toBe( + '-10 horas', + ); + }); + + it('should support days', () => { + expect(format(1 * 24 * 60 * 60 * 1000, { locale: es, long: true })).toBe( + '1 día', + ); + expect(format(1 * 24 * 60 * 60 * 1200, { locale: es, long: true })).toBe( + '1 día', + ); + expect(format(6 * 24 * 60 * 60 * 1000, { locale: es, long: true })).toBe( + '6 días', + ); + + expect( + format(-1 * 1 * 24 * 60 * 60 * 1000, { locale: es, long: true }), + ).toBe('-1 día'); + expect( + format(-1 * 1 * 24 * 60 * 60 * 1200, { locale: es, long: true }), + ).toBe('-1 día'); + expect( + format(-1 * 6 * 24 * 60 * 60 * 1000, { locale: es, long: true }), + ).toBe('-6 días'); + }); + + it('should support weeks', () => { + expect( + format(1 * 7 * 24 * 60 * 60 * 1000, { locale: es, long: true }), + ).toBe('1 semana'); + expect( + format(2 * 7 * 24 * 60 * 60 * 1000, { locale: es, long: true }), + ).toBe('2 semanas'); + + expect( + format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: es, long: true }), + ).toBe('-1 semana'); + expect( + format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: es, long: true }), + ).toBe('-2 semanas'); + }); + + it('should support months', () => { + expect( + format(30.4375 * 24 * 60 * 60 * 1000, { locale: es, long: true }), + ).toBe('1 mes'); + expect( + format(30.4375 * 24 * 60 * 60 * 1200, { locale: es, long: true }), + ).toBe('1 mes'); + expect( + format(30.4375 * 24 * 60 * 60 * 10000, { locale: es, long: true }), + ).toBe('10 meses'); + + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: es, long: true }), + ).toBe('-1 mes'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: es, long: true }), + ).toBe('-1 mes'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: es, long: true }), + ).toBe('-10 meses'); + }); + + it('should support years', () => { + expect( + format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: es, long: true }), + ).toBe('1 año'); + expect( + format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: es, long: true }), + ).toBe('1 año'); + expect( + format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: es, long: true }), + ).toBe('10 años'); + + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { + locale: es, + long: true, + }), + ).toBe('-1 año'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { + locale: es, + long: true, + }), + ).toBe('-1 año'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { + locale: es, + long: true, + }), + ).toBe('-10 años'); + }); + + it('should round', () => { + expect(format(234234234, { locale: es, long: true })).toBe('3 días'); + + expect(format(-234234234, { locale: es, long: true })).toBe('-3 días'); + }); +}); + +describe('format(number, { locale: es })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: es }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(500, { locale: es })).toBe('500ms'); + + expect(format(-500, { locale: es })).toBe('-500ms'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: es })).toBe('1s'); + expect(format(10000, { locale: es })).toBe('10s'); + + expect(format(-1000, { locale: es })).toBe('-1s'); + expect(format(-10000, { locale: es })).toBe('-10s'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: es })).toBe('1min'); + expect(format(60 * 10000, { locale: es })).toBe('10min'); + + expect(format(-1 * 60 * 1000, { locale: es })).toBe('-1min'); + expect(format(-1 * 60 * 10000, { locale: es })).toBe('-10min'); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: es })).toBe('1h'); + expect(format(60 * 60 * 10000, { locale: es })).toBe('10h'); + + expect(format(-1 * 60 * 60 * 1000, { locale: es })).toBe('-1h'); + expect(format(-1 * 60 * 60 * 10000, { locale: es })).toBe('-10h'); + }); + + it('should support days', () => { + expect(format(24 * 60 * 60 * 1000, { locale: es })).toBe('1d'); + expect(format(24 * 60 * 60 * 6000, { locale: es })).toBe('6d'); + + expect(format(-1 * 24 * 60 * 60 * 1000, { locale: es })).toBe('-1d'); + expect(format(-1 * 24 * 60 * 60 * 6000, { locale: es })).toBe('-6d'); + }); + + it('should support weeks', () => { + expect(format(1 * 7 * 24 * 60 * 60 * 1000, { locale: es })).toBe('1sem'); + expect(format(2 * 7 * 24 * 60 * 60 * 1000, { locale: es })).toBe('2sem'); + + expect(format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: es })).toBe( + '-1sem', + ); + expect(format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: es })).toBe( + '-2sem', + ); + }); + + it('should support months', () => { + expect(format(30.4375 * 24 * 60 * 60 * 1000, { locale: es })).toBe('1mes'); + expect(format(30.4375 * 24 * 60 * 60 * 1200, { locale: es })).toBe('1mes'); + expect(format(30.4375 * 24 * 60 * 60 * 10000, { locale: es })).toBe( + '10mes', + ); + + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: es })).toBe( + '-1mes', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: es })).toBe( + '-1mes', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: es })).toBe( + '-10mes', + ); + }); + + it('should support years', () => { + expect(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: es })).toBe( + '1año', + ); + expect(format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: es })).toBe( + '1año', + ); + expect(format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: es })).toBe( + '10año', + ); + + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { locale: es })).toBe( + '-1año', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { locale: es })).toBe( + '-1año', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { locale: es })).toBe( + '-10año', + ); + }); + + it('should round', () => { + expect(format(234234234, { locale: es })).toBe('3d'); + + expect(format(-234234234, { locale: es })).toBe('-3d'); + }); +}); diff --git a/src/locales/es.ts b/src/locales/es.ts new file mode 100644 index 0000000..c3faec7 --- /dev/null +++ b/src/locales/es.ts @@ -0,0 +1,25 @@ +import type { LocaleDefinition } from '../index'; + +export const es: LocaleDefinition = { + shortUnits: { + ms: 'ms', + s: 's', + m: 'min', + h: 'h', + d: 'd', + w: 'sem', + mo: 'mes', + y: 'año', + }, + longUnits: { + millisecond: ['milisegundo', 'milisegundos'], + second: ['segundo', 'segundos'], + minute: ['minuto', 'minutos'], + hour: ['hora', 'horas'], + day: ['día', 'días'], + week: ['semana', 'semanas'], + month: ['mes', 'meses'], + year: ['año', 'años'], + }, + isPlural: (v) => v !== 1, +}; diff --git a/src/locales/fr.parse-strict.test.ts b/src/locales/fr.parse-strict.test.ts new file mode 100644 index 0000000..9385455 --- /dev/null +++ b/src/locales/fr.parse-strict.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from '@jest/globals'; +import { parseStrict } from '../index'; + +// parseStrict() is typed to only accept StringValue (English units). +// Locale-specific strings that fall outside StringValue are marked @ts-expect-error. + +describe('parseStrict — fr short format', () => { + it('should round-trip milliseconds (ms = "ms")', () => { + expect(parseStrict('500ms')).toBe(500); + expect(parseStrict('-500ms')).toBe(-500); + }); + + it('should round-trip seconds (s = "s")', () => { + expect(parseStrict('1s')).toBe(1000); + expect(parseStrict('10s')).toBe(10000); + }); + + it('should round-trip minutes (m = "min")', () => { + expect(parseStrict('1min')).toBe(60 * 1000); + expect(parseStrict('10min')).toBe(60 * 10000); + }); + + it('should round-trip hours (h = "h")', () => { + expect(parseStrict('1h')).toBe(60 * 60 * 1000); + expect(parseStrict('10h')).toBe(60 * 60 * 10000); + }); + + it('should return NaN for days (d = "j" — type error: not a StringValue)', () => { + // @ts-expect-error — '1j' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1j'))).toBe(true); + }); + + it('should return NaN for weeks (w = "sem" — type error: not a StringValue)', () => { + // @ts-expect-error — '1sem' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1sem'))).toBe(true); + }); + + it('should return NaN for months (mo = "mois" — type error: not a StringValue)', () => { + // @ts-expect-error — '1mois' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1mois'))).toBe(true); + }); + + it('should return NaN for years (y = "an" — type error: not a StringValue)', () => { + // @ts-expect-error — '1an' is not a StringValue; locale unit not English + expect(Number.isNaN(parseStrict('1an'))).toBe(true); + }); +}); + +describe('parseStrict — fr long format', () => { + it('should return NaN for French millisecond words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 milliseconde'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('500 millisecondes'))).toBe(true); + }); + + it('should return NaN for French second words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 seconde'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('10 secondes'))).toBe(true); + }); + + it('should parse "minute" and "minutes" (valid StringValue — coincidental English match)', () => { + expect(parseStrict('1 minute')).toBe(60 * 1000); + expect(parseStrict('10 minutes')).toBe(60 * 10000); + }); + + it('should return NaN for French hour, day, week, month, year words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 heure'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 jour'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 semaine'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 mois'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 an'))).toBe(true); + }); +}); diff --git a/src/locales/fr.parse.test.ts b/src/locales/fr.parse.test.ts new file mode 100644 index 0000000..09ff66d --- /dev/null +++ b/src/locales/fr.parse.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from '@jest/globals'; +import { format, fr, parse } from '../index'; + +// parse() is English-only. These tests document which strings produced by +// format() with the French locale can be round-tripped through parse(). + +describe('parse — fr short format', () => { + it('should round-trip milliseconds (ms = "ms")', () => { + expect(parse(format(500, { locale: fr }))).toBe(500); // '500ms' + expect(parse(format(-500, { locale: fr }))).toBe(-500); // '-500ms' + }); + + it('should round-trip seconds (s = "s")', () => { + expect(parse(format(1000, { locale: fr }))).toBe(1000); // '1s' + expect(parse(format(10000, { locale: fr }))).toBe(10000); // '10s' + }); + + it('should round-trip minutes (m = "min")', () => { + expect(parse(format(60 * 1000, { locale: fr }))).toBe(60 * 1000); // '1min' + expect(parse(format(60 * 10000, { locale: fr }))).toBe(60 * 10000); // '10min' + }); + + it('should round-trip hours (h = "h")', () => { + expect(parse(format(60 * 60 * 1000, { locale: fr }))).toBe(60 * 60 * 1000); // '1h' + expect(parse(format(60 * 60 * 10000, { locale: fr }))).toBe( + 60 * 60 * 10000, + ); // '10h' + }); + + it('should return NaN for days (d = "j" — not an English unit)', () => { + expect( + Number.isNaN(parse(format(24 * 60 * 60 * 1000, { locale: fr }))), + ).toBe(true); // '1j' + }); + + it('should return NaN for weeks (w = "sem" — not an English unit)', () => { + expect( + Number.isNaN(parse(format(7 * 24 * 60 * 60 * 1000, { locale: fr }))), + ).toBe(true); // '1sem' + }); + + it('should return NaN for months (mo = "mois" — not an English unit)', () => { + expect( + Number.isNaN( + parse(format(30.4375 * 24 * 60 * 60 * 1000, { locale: fr })), + ), + ).toBe(true); // '1mois' + }); + + it('should return NaN for years (y = "an" — not an English unit)', () => { + expect( + Number.isNaN( + parse(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: fr })), + ), + ).toBe(true); // '1an' + }); +}); + +describe('parse — fr long format', () => { + it('should return NaN for French millisecond words', () => { + expect(Number.isNaN(parse('1 milliseconde'))).toBe(true); + expect(Number.isNaN(parse('500 millisecondes'))).toBe(true); + }); + + it('should return NaN for French second words', () => { + expect(Number.isNaN(parse('1 seconde'))).toBe(true); + expect(Number.isNaN(parse('10 secondes'))).toBe(true); + }); + + it('should parse "minute" and "minutes" (coincidental English match)', () => { + expect(parse('1 minute')).toBe(60 * 1000); + expect(parse('10 minutes')).toBe(60 * 10000); + }); + + it('should return NaN for French hour, day, week, month, year words', () => { + expect(Number.isNaN(parse('1 heure'))).toBe(true); + expect(Number.isNaN(parse('1 jour'))).toBe(true); + expect(Number.isNaN(parse('1 semaine'))).toBe(true); + expect(Number.isNaN(parse('1 mois'))).toBe(true); + expect(Number.isNaN(parse('1 an'))).toBe(true); + }); +}); diff --git a/src/locales/fr.test.ts b/src/locales/fr.test.ts new file mode 100644 index 0000000..531c161 --- /dev/null +++ b/src/locales/fr.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, it } from '@jest/globals'; +import { format, fr } from '../index'; + +describe('format(number, { locale: fr, long: true })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: fr, long: true }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(1, { locale: fr, long: true })).toBe('1 milliseconde'); + expect(format(500, { locale: fr, long: true })).toBe('500 millisecondes'); + + expect(format(-1, { locale: fr, long: true })).toBe('-1 milliseconde'); + expect(format(-500, { locale: fr, long: true })).toBe('-500 millisecondes'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: fr, long: true })).toBe('1 seconde'); + expect(format(1200, { locale: fr, long: true })).toBe('1 seconde'); + expect(format(10000, { locale: fr, long: true })).toBe('10 secondes'); + + expect(format(-1000, { locale: fr, long: true })).toBe('-1 seconde'); + expect(format(-1200, { locale: fr, long: true })).toBe('-1 seconde'); + expect(format(-10000, { locale: fr, long: true })).toBe('-10 secondes'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: fr, long: true })).toBe('1 minute'); + expect(format(60 * 1200, { locale: fr, long: true })).toBe('1 minute'); + expect(format(60 * 10000, { locale: fr, long: true })).toBe('10 minutes'); + + expect(format(-1 * 60 * 1000, { locale: fr, long: true })).toBe( + '-1 minute', + ); + expect(format(-1 * 60 * 1200, { locale: fr, long: true })).toBe( + '-1 minute', + ); + expect(format(-1 * 60 * 10000, { locale: fr, long: true })).toBe( + '-10 minutes', + ); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: fr, long: true })).toBe('1 heure'); + expect(format(60 * 60 * 1200, { locale: fr, long: true })).toBe('1 heure'); + expect(format(60 * 60 * 10000, { locale: fr, long: true })).toBe( + '10 heures', + ); + + expect(format(-1 * 60 * 60 * 1000, { locale: fr, long: true })).toBe( + '-1 heure', + ); + expect(format(-1 * 60 * 60 * 1200, { locale: fr, long: true })).toBe( + '-1 heure', + ); + expect(format(-1 * 60 * 60 * 10000, { locale: fr, long: true })).toBe( + '-10 heures', + ); + }); + + it('should support days', () => { + expect(format(1 * 24 * 60 * 60 * 1000, { locale: fr, long: true })).toBe( + '1 jour', + ); + expect(format(1 * 24 * 60 * 60 * 1200, { locale: fr, long: true })).toBe( + '1 jour', + ); + expect(format(6 * 24 * 60 * 60 * 1000, { locale: fr, long: true })).toBe( + '6 jours', + ); + + expect( + format(-1 * 1 * 24 * 60 * 60 * 1000, { locale: fr, long: true }), + ).toBe('-1 jour'); + expect( + format(-1 * 1 * 24 * 60 * 60 * 1200, { locale: fr, long: true }), + ).toBe('-1 jour'); + expect( + format(-1 * 6 * 24 * 60 * 60 * 1000, { locale: fr, long: true }), + ).toBe('-6 jours'); + }); + + it('should support weeks', () => { + expect( + format(1 * 7 * 24 * 60 * 60 * 1000, { locale: fr, long: true }), + ).toBe('1 semaine'); + expect( + format(2 * 7 * 24 * 60 * 60 * 1000, { locale: fr, long: true }), + ).toBe('2 semaines'); + + expect( + format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: fr, long: true }), + ).toBe('-1 semaine'); + expect( + format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: fr, long: true }), + ).toBe('-2 semaines'); + }); + + it('should support months', () => { + expect( + format(30.4375 * 24 * 60 * 60 * 1000, { locale: fr, long: true }), + ).toBe('1 mois'); + expect( + format(30.4375 * 24 * 60 * 60 * 1200, { locale: fr, long: true }), + ).toBe('1 mois'); + expect( + format(30.4375 * 24 * 60 * 60 * 10000, { locale: fr, long: true }), + ).toBe('10 mois'); + + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: fr, long: true }), + ).toBe('-1 mois'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: fr, long: true }), + ).toBe('-1 mois'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: fr, long: true }), + ).toBe('-10 mois'); + }); + + it('should support years', () => { + expect( + format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: fr, long: true }), + ).toBe('1 an'); + expect( + format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: fr, long: true }), + ).toBe('1 an'); + expect( + format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: fr, long: true }), + ).toBe('10 ans'); + + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { + locale: fr, + long: true, + }), + ).toBe('-1 an'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { + locale: fr, + long: true, + }), + ).toBe('-1 an'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { + locale: fr, + long: true, + }), + ).toBe('-10 ans'); + }); + + it('should round', () => { + expect(format(234234234, { locale: fr, long: true })).toBe('3 jours'); + + expect(format(-234234234, { locale: fr, long: true })).toBe('-3 jours'); + }); +}); + +describe('format(number, { locale: fr })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: fr }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(500, { locale: fr })).toBe('500ms'); + + expect(format(-500, { locale: fr })).toBe('-500ms'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: fr })).toBe('1s'); + expect(format(10000, { locale: fr })).toBe('10s'); + + expect(format(-1000, { locale: fr })).toBe('-1s'); + expect(format(-10000, { locale: fr })).toBe('-10s'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: fr })).toBe('1min'); + expect(format(60 * 10000, { locale: fr })).toBe('10min'); + + expect(format(-1 * 60 * 1000, { locale: fr })).toBe('-1min'); + expect(format(-1 * 60 * 10000, { locale: fr })).toBe('-10min'); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: fr })).toBe('1h'); + expect(format(60 * 60 * 10000, { locale: fr })).toBe('10h'); + + expect(format(-1 * 60 * 60 * 1000, { locale: fr })).toBe('-1h'); + expect(format(-1 * 60 * 60 * 10000, { locale: fr })).toBe('-10h'); + }); + + it('should support days', () => { + expect(format(24 * 60 * 60 * 1000, { locale: fr })).toBe('1j'); + expect(format(24 * 60 * 60 * 6000, { locale: fr })).toBe('6j'); + + expect(format(-1 * 24 * 60 * 60 * 1000, { locale: fr })).toBe('-1j'); + expect(format(-1 * 24 * 60 * 60 * 6000, { locale: fr })).toBe('-6j'); + }); + + it('should support weeks', () => { + expect(format(1 * 7 * 24 * 60 * 60 * 1000, { locale: fr })).toBe('1sem'); + expect(format(2 * 7 * 24 * 60 * 60 * 1000, { locale: fr })).toBe('2sem'); + + expect(format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: fr })).toBe( + '-1sem', + ); + expect(format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: fr })).toBe( + '-2sem', + ); + }); + + it('should support months', () => { + expect(format(30.4375 * 24 * 60 * 60 * 1000, { locale: fr })).toBe('1mois'); + expect(format(30.4375 * 24 * 60 * 60 * 1200, { locale: fr })).toBe('1mois'); + expect(format(30.4375 * 24 * 60 * 60 * 10000, { locale: fr })).toBe( + '10mois', + ); + + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: fr })).toBe( + '-1mois', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: fr })).toBe( + '-1mois', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: fr })).toBe( + '-10mois', + ); + }); + + it('should support years', () => { + expect(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: fr })).toBe( + '1an', + ); + expect(format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: fr })).toBe( + '1an', + ); + expect(format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: fr })).toBe( + '10an', + ); + + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { locale: fr })).toBe( + '-1an', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { locale: fr })).toBe( + '-1an', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { locale: fr })).toBe( + '-10an', + ); + }); + + it('should round', () => { + expect(format(234234234, { locale: fr })).toBe('3j'); + + expect(format(-234234234, { locale: fr })).toBe('-3j'); + }); +}); diff --git a/src/locales/fr.ts b/src/locales/fr.ts new file mode 100644 index 0000000..994f4f0 --- /dev/null +++ b/src/locales/fr.ts @@ -0,0 +1,25 @@ +import type { LocaleDefinition } from '../index'; + +export const fr: LocaleDefinition = { + shortUnits: { + ms: 'ms', + s: 's', + m: 'min', + h: 'h', + d: 'j', + w: 'sem', + mo: 'mois', + y: 'an', + }, + longUnits: { + millisecond: ['milliseconde', 'millisecondes'], + second: ['seconde', 'secondes'], + minute: ['minute', 'minutes'], + hour: ['heure', 'heures'], + day: ['jour', 'jours'], + week: ['semaine', 'semaines'], + month: ['mois', 'mois'], + year: ['an', 'ans'], + }, + isPlural: (v) => v !== 1, +}; diff --git a/src/locales/zh.parse-strict.test.ts b/src/locales/zh.parse-strict.test.ts new file mode 100644 index 0000000..93ba141 --- /dev/null +++ b/src/locales/zh.parse-strict.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from '@jest/globals'; +import { parseStrict } from '../index'; + +// parseStrict() is typed to only accept StringValue (English units). +// All Chinese locale output uses CJK characters — none are valid StringValue. + +describe('parseStrict — zh short format', () => { + it('should return NaN for all Chinese short units (type errors)', () => { + // @ts-expect-error — CJK characters, not a StringValue + expect(Number.isNaN(parseStrict('500毫秒'))).toBe(true); + // @ts-expect-error — CJK characters, not a StringValue + expect(Number.isNaN(parseStrict('1秒'))).toBe(true); + // @ts-expect-error — CJK characters, not a StringValue + expect(Number.isNaN(parseStrict('1分'))).toBe(true); + // @ts-expect-error — CJK characters, not a StringValue + expect(Number.isNaN(parseStrict('1时'))).toBe(true); + // @ts-expect-error — CJK characters, not a StringValue + expect(Number.isNaN(parseStrict('1天'))).toBe(true); + // @ts-expect-error — CJK characters, not a StringValue + expect(Number.isNaN(parseStrict('1周'))).toBe(true); + // @ts-expect-error — CJK characters, not a StringValue + expect(Number.isNaN(parseStrict('1月'))).toBe(true); + // @ts-expect-error — CJK characters, not a StringValue + expect(Number.isNaN(parseStrict('1年'))).toBe(true); + }); +}); + +describe('parseStrict — zh long format', () => { + it('should return NaN for all Chinese long unit words (type errors)', () => { + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 毫秒'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 秒'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 分钟'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 小时'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 天'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 周'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 月'))).toBe(true); + // @ts-expect-error — not a StringValue + expect(Number.isNaN(parseStrict('1 年'))).toBe(true); + }); +}); diff --git a/src/locales/zh.parse.test.ts b/src/locales/zh.parse.test.ts new file mode 100644 index 0000000..6a1de13 --- /dev/null +++ b/src/locales/zh.parse.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from '@jest/globals'; +import { format, parse, zh } from '../index'; + +// parse() is English-only. All Chinese locale output uses CJK characters, +// so no locale-formatted strings can be round-tripped through parse(). + +describe('parse — zh short format', () => { + it('should return NaN for all Chinese short units', () => { + expect(Number.isNaN(parse(format(500, { locale: zh })))).toBe(true); // '500毫秒' + expect(Number.isNaN(parse(format(1000, { locale: zh })))).toBe(true); // '1秒' + expect(Number.isNaN(parse(format(60 * 1000, { locale: zh })))).toBe(true); // '1分' + expect(Number.isNaN(parse(format(60 * 60 * 1000, { locale: zh })))).toBe( + true, + ); // '1时' + expect( + Number.isNaN(parse(format(24 * 60 * 60 * 1000, { locale: zh }))), + ).toBe(true); // '1天' + expect( + Number.isNaN(parse(format(7 * 24 * 60 * 60 * 1000, { locale: zh }))), + ).toBe(true); // '1周' + expect( + Number.isNaN( + parse(format(30.4375 * 24 * 60 * 60 * 1000, { locale: zh })), + ), + ).toBe(true); // '1月' + expect( + Number.isNaN( + parse(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: zh })), + ), + ).toBe(true); // '1年' + }); +}); + +describe('parse — zh long format', () => { + it('should return NaN for all Chinese long unit words', () => { + expect(Number.isNaN(parse('1 毫秒'))).toBe(true); + expect(Number.isNaN(parse('1 秒'))).toBe(true); + expect(Number.isNaN(parse('1 分钟'))).toBe(true); + expect(Number.isNaN(parse('1 小时'))).toBe(true); + expect(Number.isNaN(parse('1 天'))).toBe(true); + expect(Number.isNaN(parse('1 周'))).toBe(true); + expect(Number.isNaN(parse('1 月'))).toBe(true); + expect(Number.isNaN(parse('1 年'))).toBe(true); + }); +}); diff --git a/src/locales/zh.test.ts b/src/locales/zh.test.ts new file mode 100644 index 0000000..95473e6 --- /dev/null +++ b/src/locales/zh.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from '@jest/globals'; +import { format, zh } from '../index'; + +describe('format(number, { locale: zh, long: true })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: zh, long: true }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(1, { locale: zh, long: true })).toBe('1 毫秒'); + expect(format(500, { locale: zh, long: true })).toBe('500 毫秒'); + + expect(format(-1, { locale: zh, long: true })).toBe('-1 毫秒'); + expect(format(-500, { locale: zh, long: true })).toBe('-500 毫秒'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: zh, long: true })).toBe('1 秒'); + expect(format(1200, { locale: zh, long: true })).toBe('1 秒'); + expect(format(10000, { locale: zh, long: true })).toBe('10 秒'); + + expect(format(-1000, { locale: zh, long: true })).toBe('-1 秒'); + expect(format(-1200, { locale: zh, long: true })).toBe('-1 秒'); + expect(format(-10000, { locale: zh, long: true })).toBe('-10 秒'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: zh, long: true })).toBe('1 分钟'); + expect(format(60 * 1200, { locale: zh, long: true })).toBe('1 分钟'); + expect(format(60 * 10000, { locale: zh, long: true })).toBe('10 分钟'); + + expect(format(-1 * 60 * 1000, { locale: zh, long: true })).toBe('-1 分钟'); + expect(format(-1 * 60 * 1200, { locale: zh, long: true })).toBe('-1 分钟'); + expect(format(-1 * 60 * 10000, { locale: zh, long: true })).toBe( + '-10 分钟', + ); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: zh, long: true })).toBe('1 小时'); + expect(format(60 * 60 * 1200, { locale: zh, long: true })).toBe('1 小时'); + expect(format(60 * 60 * 10000, { locale: zh, long: true })).toBe('10 小时'); + + expect(format(-1 * 60 * 60 * 1000, { locale: zh, long: true })).toBe( + '-1 小时', + ); + expect(format(-1 * 60 * 60 * 1200, { locale: zh, long: true })).toBe( + '-1 小时', + ); + expect(format(-1 * 60 * 60 * 10000, { locale: zh, long: true })).toBe( + '-10 小时', + ); + }); + + it('should support days', () => { + expect(format(1 * 24 * 60 * 60 * 1000, { locale: zh, long: true })).toBe( + '1 天', + ); + expect(format(1 * 24 * 60 * 60 * 1200, { locale: zh, long: true })).toBe( + '1 天', + ); + expect(format(6 * 24 * 60 * 60 * 1000, { locale: zh, long: true })).toBe( + '6 天', + ); + + expect( + format(-1 * 1 * 24 * 60 * 60 * 1000, { locale: zh, long: true }), + ).toBe('-1 天'); + expect( + format(-1 * 1 * 24 * 60 * 60 * 1200, { locale: zh, long: true }), + ).toBe('-1 天'); + expect( + format(-1 * 6 * 24 * 60 * 60 * 1000, { locale: zh, long: true }), + ).toBe('-6 天'); + }); + + it('should support weeks', () => { + expect( + format(1 * 7 * 24 * 60 * 60 * 1000, { locale: zh, long: true }), + ).toBe('1 周'); + expect( + format(2 * 7 * 24 * 60 * 60 * 1000, { locale: zh, long: true }), + ).toBe('2 周'); + + expect( + format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: zh, long: true }), + ).toBe('-1 周'); + expect( + format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: zh, long: true }), + ).toBe('-2 周'); + }); + + it('should support months', () => { + expect( + format(30.4375 * 24 * 60 * 60 * 1000, { locale: zh, long: true }), + ).toBe('1 月'); + expect( + format(30.4375 * 24 * 60 * 60 * 1200, { locale: zh, long: true }), + ).toBe('1 月'); + expect( + format(30.4375 * 24 * 60 * 60 * 10000, { locale: zh, long: true }), + ).toBe('10 月'); + + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: zh, long: true }), + ).toBe('-1 月'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: zh, long: true }), + ).toBe('-1 月'); + expect( + format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: zh, long: true }), + ).toBe('-10 月'); + }); + + it('should support years', () => { + expect( + format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: zh, long: true }), + ).toBe('1 年'); + expect( + format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: zh, long: true }), + ).toBe('1 年'); + expect( + format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: zh, long: true }), + ).toBe('10 年'); + + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { + locale: zh, + long: true, + }), + ).toBe('-1 年'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { + locale: zh, + long: true, + }), + ).toBe('-1 年'); + expect( + format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { + locale: zh, + long: true, + }), + ).toBe('-10 年'); + }); + + it('should round', () => { + expect(format(234234234, { locale: zh, long: true })).toBe('3 天'); + + expect(format(-234234234, { locale: zh, long: true })).toBe('-3 天'); + }); +}); + +describe('format(number, { locale: zh })', () => { + it('should not throw an error', () => { + expect(() => { + format(500, { locale: zh }); + }).not.toThrow(); + }); + + it('should support milliseconds', () => { + expect(format(500, { locale: zh })).toBe('500毫秒'); + + expect(format(-500, { locale: zh })).toBe('-500毫秒'); + }); + + it('should support seconds', () => { + expect(format(1000, { locale: zh })).toBe('1秒'); + expect(format(10000, { locale: zh })).toBe('10秒'); + + expect(format(-1000, { locale: zh })).toBe('-1秒'); + expect(format(-10000, { locale: zh })).toBe('-10秒'); + }); + + it('should support minutes', () => { + expect(format(60 * 1000, { locale: zh })).toBe('1分'); + expect(format(60 * 10000, { locale: zh })).toBe('10分'); + + expect(format(-1 * 60 * 1000, { locale: zh })).toBe('-1分'); + expect(format(-1 * 60 * 10000, { locale: zh })).toBe('-10分'); + }); + + it('should support hours', () => { + expect(format(60 * 60 * 1000, { locale: zh })).toBe('1时'); + expect(format(60 * 60 * 10000, { locale: zh })).toBe('10时'); + + expect(format(-1 * 60 * 60 * 1000, { locale: zh })).toBe('-1时'); + expect(format(-1 * 60 * 60 * 10000, { locale: zh })).toBe('-10时'); + }); + + it('should support days', () => { + expect(format(24 * 60 * 60 * 1000, { locale: zh })).toBe('1天'); + expect(format(24 * 60 * 60 * 6000, { locale: zh })).toBe('6天'); + + expect(format(-1 * 24 * 60 * 60 * 1000, { locale: zh })).toBe('-1天'); + expect(format(-1 * 24 * 60 * 60 * 6000, { locale: zh })).toBe('-6天'); + }); + + it('should support weeks', () => { + expect(format(1 * 7 * 24 * 60 * 60 * 1000, { locale: zh })).toBe('1周'); + expect(format(2 * 7 * 24 * 60 * 60 * 1000, { locale: zh })).toBe('2周'); + + expect(format(-1 * 1 * 7 * 24 * 60 * 60 * 1000, { locale: zh })).toBe( + '-1周', + ); + expect(format(-1 * 2 * 7 * 24 * 60 * 60 * 1000, { locale: zh })).toBe( + '-2周', + ); + }); + + it('should support months', () => { + expect(format(30.4375 * 24 * 60 * 60 * 1000, { locale: zh })).toBe('1月'); + expect(format(30.4375 * 24 * 60 * 60 * 1200, { locale: zh })).toBe('1月'); + expect(format(30.4375 * 24 * 60 * 60 * 10000, { locale: zh })).toBe('10月'); + + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1000, { locale: zh })).toBe( + '-1月', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 1200, { locale: zh })).toBe( + '-1月', + ); + expect(format(-1 * 30.4375 * 24 * 60 * 60 * 10000, { locale: zh })).toBe( + '-10月', + ); + }); + + it('should support years', () => { + expect(format(365.25 * 24 * 60 * 60 * 1000 + 1, { locale: zh })).toBe( + '1年', + ); + expect(format(365.25 * 24 * 60 * 60 * 1200 + 1, { locale: zh })).toBe( + '1年', + ); + expect(format(365.25 * 24 * 60 * 60 * 10000 + 1, { locale: zh })).toBe( + '10年', + ); + + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1000 - 1, { locale: zh })).toBe( + '-1年', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 1200 - 1, { locale: zh })).toBe( + '-1年', + ); + expect(format(-1 * 365.25 * 24 * 60 * 60 * 10000 - 1, { locale: zh })).toBe( + '-10年', + ); + }); + + it('should round', () => { + expect(format(234234234, { locale: zh })).toBe('3天'); + + expect(format(-234234234, { locale: zh })).toBe('-3天'); + }); +}); diff --git a/src/locales/zh.ts b/src/locales/zh.ts new file mode 100644 index 0000000..64d585b --- /dev/null +++ b/src/locales/zh.ts @@ -0,0 +1,25 @@ +import type { LocaleDefinition } from '../index'; + +export const zh: LocaleDefinition = { + shortUnits: { + ms: '毫秒', + s: '秒', + m: '分', + h: '时', + d: '天', + w: '周', + mo: '月', + y: '年', + }, + longUnits: { + millisecond: ['毫秒', '毫秒'], + second: ['秒', '秒'], + minute: ['分钟', '分钟'], + hour: ['小时', '小时'], + day: ['天', '天'], + week: ['周', '周'], + month: ['月', '月'], + year: ['年', '年'], + }, + isPlural: () => false, +}; diff --git a/tsconfig.json b/tsconfig.json index c1511c4..240ae23 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,5 +14,5 @@ "noImplicitReturns": true, "noFallthroughCasesInSwitch": true }, - "include": ["src/*.ts"] + "include": ["src/**/*.ts"] }