diff --git a/src/i18n/__tests__/format.test.ts b/src/i18n/__tests__/format.test.ts index cd7add6b..6ca28366 100644 --- a/src/i18n/__tests__/format.test.ts +++ b/src/i18n/__tests__/format.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; -import { formatMessage, pluralCategory } from '../format'; +import { describe, it, expect, vi } from 'vitest'; +import { builtinPluralCategory, formatMessage, pluralCategory } from '../format'; describe('formatMessage', () => { it('substitutes simple arguments', () => { @@ -66,3 +66,97 @@ describe('pluralCategory', () => { expect(pluralCategory(7, 'zz-not-a-locale')).toBe('other'); }); }); + +// The app's locales (src/i18n/index.ts SUPPORTED_LOCALES). +const APP_LOCALES = [ + 'ar', 'ca', 'cs', 'sk', 'da', 'de', 'en', 'fa', 'es', 'fr', 'he', 'it', 'hu', 'lv', + 'nl', 'nb', 'pl', 'pt', 'ro', 'tr', 'ru', 'uk', 'ko', 'ja', 'mn', 'zh', 'zh-TW', +]; + +describe('builtinPluralCategory (Hermes has no Intl.PluralRules)', () => { + // Sample numbers from the CLDR language plural rules chart. + const CLDR_EXAMPLES: Record>> = { + ru: { one: [1, 21, 31, 101, 1001], few: [2, 3, 4, 22, 24, 102, 1002], many: [0, 5, 11, 12, 14, 19, 20, 25, 100, 111, 1000], other: [0.5, 1.5, 10.1] }, + uk: { one: [1, 21, 101], few: [2, 4, 22, 104], many: [0, 5, 11, 14, 20, 100, 1000], other: [0.5, 1.5] }, + pl: { one: [1], few: [2, 3, 4, 22, 24, 102, 1002], many: [0, 5, 11, 12, 14, 19, 21, 25, 100, 112, 1000], other: [0.5, 1.5, 10.1] }, + cs: { one: [1], few: [2, 3, 4], many: [0.5, 1.5, 10.1], other: [0, 5, 19, 100, 1000] }, + sk: { one: [1], few: [2, 3, 4], many: [0.5, 1.5], other: [0, 5, 100] }, + lv: { zero: [0, 10, 11, 19, 20, 30, 100, 1000, 0.11], one: [1, 21, 31, 101, 0.1, 1.1, 0.01, 0.21], other: [2, 9, 22, 29, 102, 0.2, 0.02, 1.5] }, + ar: { zero: [0], one: [1], two: [2], few: [3, 10, 103, 110, 1003], many: [11, 26, 99, 111, 1011], other: [100, 101, 102, 200, 1000, 0.1, 1.5] }, + he: { one: [1, 0.5, 0.1], two: [2], other: [0, 3, 10, 17, 20, 100, 1000, 1.5] }, + ro: { one: [1], few: [0, 2, 16, 19, 101, 119, 1001, 0.5, 1.5], other: [20, 35, 100, 120, 1000] }, + fr: { one: [0, 1, 1.5], many: [1000000, 2000000], other: [2, 17, 100, 1000, 10000] }, + es: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1000, 0.5, 1.5] }, + it: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1.5] }, + ca: { one: [1], many: [1000000], other: [0, 2, 16, 100, 1.5] }, + pt: { one: [0, 1, 0.5, 1.5], many: [1000000], other: [2, 17, 100, 1000] }, + da: { one: [1, 0.1, 1.6], other: [0, 2, 16, 100, 2.5] }, + fa: { one: [0, 1, 0.5], other: [2, 17, 100, 1.5] }, + de: { one: [1], other: [0, 2, 16, 100, 1.5] }, + en: { one: [1], other: [0, 2, 16, 100, 1.5] }, + nl: { one: [1], other: [0, 2, 1.5] }, + hu: { one: [1], other: [0, 2, 1.5] }, + nb: { one: [1], other: [0, 2, 1.5] }, + tr: { one: [1], other: [0, 2, 1.5] }, + mn: { one: [1], other: [0, 2, 1.5] }, + ko: { other: [0, 1, 2, 1.5] }, + ja: { other: [0, 1, 2, 1.5] }, + zh: { other: [0, 1, 2, 1.5] }, + 'zh-TW': { other: [0, 1, 2, 1.5] }, + }; + + for (const [locale, categories] of Object.entries(CLDR_EXAMPLES)) { + it(`follows the CLDR examples for ${locale}`, () => { + for (const [category, samples] of Object.entries(categories)) { + for (const n of samples ?? []) { + expect(`${n} → ${builtinPluralCategory(n, locale)}`).toBe(`${n} → ${category}`); + } + } + }); + } + + it('covers every app locale', () => { + expect(Object.keys(CLDR_EXAMPLES).sort()).toEqual([...APP_LOCALES].sort()); + }); + + it('agrees with the engine Intl.PluralRules for every app locale', () => { + const samples: number[] = []; + for (let n = 0; n <= 1200; n++) samples.push(n); + samples.push(1000000, 2000000, 1234567, 10000000, -1, -2, -5); + for (let d = 0; d <= 40; d++) samples.push(d / 10, d / 100 + 1, d / 100 + 20); + const mismatches: string[] = []; + for (const locale of APP_LOCALES) { + const intl = new Intl.PluralRules(locale); + for (const n of samples) { + const expected = intl.select(n); + const actual = builtinPluralCategory(n, locale); + if (actual !== expected) mismatches.push(`${locale} ${n}: ${actual}, Intl says ${expected}`); + } + } + expect(mismatches.slice(0, 20)).toEqual([]); + }); + + it('uses the English rule for languages it has no rule for', () => { + expect(builtinPluralCategory(1, 'xx')).toBe('one'); + expect(builtinPluralCategory(2, 'xx')).toBe('other'); + }); +}); + +describe('formatMessage without Intl.PluralRules', () => { + it('picks the plural form from the built-in rules', async () => { + const intl = Intl as unknown as { PluralRules: unknown }; + const original = intl.PluralRules; + intl.PluralRules = undefined; + try { + vi.resetModules(); + const fresh = await import('../format'); + const msg = '{count, plural, one {# письмо} few {# письма} many {# писем} other {# письма}}'; + expect(fresh.formatMessage(msg, { count: 2 }, 'ru')).toBe('2 письма'); + expect(fresh.formatMessage(msg, { count: 5 }, 'ru')).toBe('5 писем'); + expect(fresh.formatMessage(msg, { count: 21 }, 'ru')).toBe('21 письмо'); + expect(fresh.pluralCategory(0, 'fr')).toBe('one'); + } finally { + intl.PluralRules = original; + } + }); +}); diff --git a/src/i18n/format.ts b/src/i18n/format.ts index 2584bab4..99014bdc 100644 --- a/src/i18n/format.ts +++ b/src/i18n/format.ts @@ -30,10 +30,132 @@ function pluralRulesFor(locale: string): Intl.PluralRules | null { return rules; } +// ─── Built-in CLDR cardinal rules ───────────────────────────────────────── +// Hermes ships no Intl.PluralRules, so without these every locale would get +// the English one/other split ("2 писем" instead of "2 письма"). These are the +// CLDR 48 cardinal rules (unicode.org/cldr/charts/latest/supplemental/ +// language_plural_rules.html) for the app's locales, written out from the +// rule text. Operands as in UTS #35: n absolute value, i integer digits, +// v number of visible fraction digits, f visible fraction digits (with +// Intl's default formatting there are no trailing zeros, so t = f). + +interface PluralOperands { + n: number; + i: number; + v: number; + f: number; +} + +function pluralOperands(value: number): PluralOperands { + // Intl.PluralRules formats with at most three fraction digits by default. + const n = Number(Math.abs(value).toFixed(3)); + if (Number.isInteger(n)) return { n, i: n, v: 0, f: 0 }; + const [int, frac] = String(n).split('.'); + return { n, i: Number(int), v: frac.length, f: Number(frac) }; +} + +// CLDR ranges (`a..b`) only match integers: 3.5 is not in 3..10. +function inRange(x: number, lo: number, hi: number): boolean { + return Number.isInteger(x) && x >= lo && x <= hi; +} + +type PluralRule = (o: PluralOperands) => PluralCategory; + +// "many" for whole millions (fr "1 million de …"): e = 0 and i != 0 and +// i % 1000000 = 0 and v = 0. Plain numbers never use compact exponents. +const isMillions = ({ i, v }: PluralOperands) => v === 0 && i !== 0 && i % 1000000 === 0; + +const oneForIntegerOne: PluralRule = ({ i, v }) => (i === 1 && v === 0 ? 'one' : 'other'); +const oneForOne: PluralRule = ({ n }) => (n === 1 ? 'one' : 'other'); +const otherOnly: PluralRule = () => 'other'; + +// ru, uk +const eastSlavic: PluralRule = ({ i, v }) => { + if (v !== 0) return 'other'; + if (i % 10 === 1 && i % 100 !== 11) return 'one'; + if (inRange(i % 10, 2, 4) && !inRange(i % 100, 12, 14)) return 'few'; + return 'many'; +}; + +// cs, sk +const czechSlovak: PluralRule = ({ i, v }) => { + if (v !== 0) return 'many'; + if (i === 1) return 'one'; + if (inRange(i, 2, 4)) return 'few'; + return 'other'; +}; + +const PLURAL_RULES: Record = { + ar: ({ n }) => { + if (n === 0) return 'zero'; + if (n === 1) return 'one'; + if (n === 2) return 'two'; + if (inRange(n % 100, 3, 10)) return 'few'; + if (inRange(n % 100, 11, 99)) return 'many'; + return 'other'; + }, + ca: (o) => (o.i === 1 && o.v === 0 ? 'one' : isMillions(o) ? 'many' : 'other'), + cs: czechSlovak, + sk: czechSlovak, + da: ({ n, i, f }) => (n === 1 || (f !== 0 && (i === 0 || i === 1)) ? 'one' : 'other'), + de: oneForIntegerOne, + en: oneForIntegerOne, + fa: ({ n, i }) => (i === 0 || n === 1 ? 'one' : 'other'), + es: (o) => (o.n === 1 ? 'one' : isMillions(o) ? 'many' : 'other'), + fr: (o) => (o.i === 0 || o.i === 1 ? 'one' : isMillions(o) ? 'many' : 'other'), + he: ({ i, v }) => { + if ((i === 1 && v === 0) || (i === 0 && v !== 0)) return 'one'; + if (i === 2 && v === 0) return 'two'; + return 'other'; + }, + it: (o) => (o.i === 1 && o.v === 0 ? 'one' : isMillions(o) ? 'many' : 'other'), + hu: oneForOne, + lv: ({ n, v, f }) => { + if (n % 10 === 0 || inRange(n % 100, 11, 19) || (v === 2 && inRange(f % 100, 11, 19))) return 'zero'; + if ( + (n % 10 === 1 && n % 100 !== 11) + || (v === 2 && f % 10 === 1 && f % 100 !== 11) + || (v !== 2 && f % 10 === 1) + ) return 'one'; + return 'other'; + }, + nl: oneForIntegerOne, + nb: oneForOne, + pl: ({ i, v }) => { + if (v !== 0) return 'other'; + if (i === 1) return 'one'; + if (inRange(i % 10, 2, 4) && !inRange(i % 100, 12, 14)) return 'few'; + return 'many'; + }, + pt: (o) => (o.i === 0 || o.i === 1 ? 'one' : isMillions(o) ? 'many' : 'other'), + ro: ({ n, i, v }) => { + if (i === 1 && v === 0) return 'one'; + if (v !== 0 || n === 0 || (n !== 1 && inRange(n % 100, 1, 19))) return 'few'; + return 'other'; + }, + tr: oneForOne, + ru: eastSlavic, + uk: eastSlavic, + ko: otherOnly, + ja: otherOnly, + mn: oneForOne, + zh: otherOnly, +}; + /** - * CLDR plural category for `n` in `locale`. Falls back to the English rule - * (1 → one, everything else → other) when Intl.PluralRules is unavailable - * (older Hermes builds) or rejects the locale. + * Plural category from the built-in CLDR rules above, by language subtag + * (`zh-TW` → `zh`). Languages without a rule get the English one. + */ +export function builtinPluralCategory(n: number, locale: string): PluralCategory { + const language = locale.toLowerCase().split(/[-_]/)[0]; + const rule = PLURAL_RULES[language] ?? oneForIntegerOne; + return rule(pluralOperands(n)); +} + +/** + * CLDR plural category for `n` in `locale`. Uses Intl.PluralRules where the + * engine has it and the built-in rules where it doesn't (Hermes) or rejects + * the locale. */ export function pluralCategory(n: number, locale: string): PluralCategory { const rules = pluralRulesFor(locale); @@ -44,7 +166,7 @@ export function pluralCategory(n: number, locale: string): PluralCategory { // fall through } } - return n === 1 ? 'one' : 'other'; + return builtinPluralCategory(n, locale); } // Find the index of the `}` that closes the `{` at `open`, honouring nesting.