Newer
Older
navi-1 / webclient / tests / unit / i18n / index.test.js
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'
import { nextTick } from 'vue'

import {
  LOCALE_STORAGE_KEY,
  SUPPORTED_LOCALES,
  locale,
  normalizeTag,
  pluralCategory,
  rememberLocale,
  setLocale,
  storedLocale,
  t,
} from '@/i18n/index.js'
import en from '@/i18n/messages/en.js'
import ru from '@/i18n/messages/ru.js'
import uk from '@/i18n/messages/uk.js'

// A slug added to one dictionary and forgotten in another falls back to
// English silently, so the screen just comes out half-translated. Compare the
// key sets instead of trusting everyone to remember.
describe('the dictionaries', () => {
  it('carry the same slugs', () => {
    const reference = Object.keys(en).sort()

    expect(Object.keys(uk).sort()).toEqual(reference)
    expect(Object.keys(ru).sort()).toEqual(reference)
  })
})

describe('language tags', () => {
  it('ships exactly the three languages of the handbook', () => {
    expect(SUPPORTED_LOCALES).toEqual(['en', 'uk', 'ru'])
  })

  it('reads the language prefix out of a tag', () => {
    expect(normalizeTag('en-US')).toBe('en')
    expect(normalizeTag('uk_UA')).toBe('uk')
    expect(normalizeTag(' RU ')).toBe('ru')
  })

  it('refuses a language it has no dictionary for', () => {
    expect(normalizeTag('de')).toBeNull()
    expect(normalizeTag('')).toBeNull()
    expect(normalizeTag(null)).toBeNull()
  })

  it('falls back to English for a tag it cannot use', () => {
    setLocale('de')
    expect(locale.value).toBe('en')
  })

  it('follows the chosen language into the document', async () => {
    setLocale('uk')
    await nextTick()
    expect(document.documentElement.lang).toBe('uk')

    setLocale('en')
    await nextTick()
    expect(document.documentElement.lang).toBe('en')
  })
})

describe('translating', () => {
  beforeEach(() => setLocale('en'))

  it('answers in the chosen language', () => {
    setLocale('uk')
    expect(t('toast.languageSaved')).toBe('Мову збережено')

    setLocale('ru')
    expect(t('toast.languageSaved')).toBe('Язык сохранён')
  })

  // The dictionaries are filled in area by area, so a language is expected to
  // be missing slugs for a while — the interface must still be readable.
  describe('when a dictionary is not complete yet', () => {
    afterEach(() => {
      ru['toast.languageSaved'] = 'Язык сохранён'
    })

    it('falls back to English for a slug the language does not carry', () => {
      delete ru['toast.languageSaved']
      setLocale('ru')

      expect(t('toast.languageSaved')).toBe('Language saved')
    })

    it('shows the slug itself rather than nothing when nobody has it', () => {
      expect(t('nope.not.a.slug')).toBe('nope.not.a.slug')
    })
  })

  it('interpolates parameters', () => {
    en['test.greeting'] = 'Hello {name}, {n} new'
    try {
      expect(t('test.greeting', { name: 'Ada', n: 3 })).toBe('Hello Ada, 3 new')
      // A parameter nobody passed stays visible instead of turning into "undefined"
      expect(t('test.greeting', { name: 'Ada' })).toBe('Hello Ada, {n} new')
    } finally {
      delete en['test.greeting']
    }
  })

  it('picks the plural form from n', () => {
    en['test.items'] = { one: '{n} item', other: '{n} items' }
    ru['test.items'] = { one: '{n} вещь', few: '{n} вещи', many: '{n} вещей' }
    try {
      setLocale('en')
      expect(t('test.items', { n: 1 })).toBe('1 item')
      expect(t('test.items', { n: 2 })).toBe('2 items')

      setLocale('ru')
      expect(t('test.items', { n: 1 })).toBe('1 вещь')
      expect(t('test.items', { n: 3 })).toBe('3 вещи')
      expect(t('test.items', { n: 5 })).toBe('5 вещей')
      expect(t('test.items', { n: 21 })).toBe('21 вещь')
      expect(t('test.items', { n: 12 })).toBe('12 вещей')
    } finally {
      delete en['test.items']
      delete ru['test.items']
    }
  })
})

// The Slavic categories are the part of pluralisation that is easy to get
// subtly wrong, so they are checked on their own.
describe('plural categories', () => {
  it('uses one/few/many for ru and uk', () => {
    for (const lang of ['ru', 'uk']) {
      expect([1, 21, 101].map(n => pluralCategory(lang, n))).toEqual(['one', 'one', 'one'])
      expect([2, 3, 4, 22].map(n => pluralCategory(lang, n))).toEqual(['few', 'few', 'few', 'few'])
      expect([0, 5, 11, 12, 14, 25].map(n => pluralCategory(lang, n)))
        .toEqual(['many', 'many', 'many', 'many', 'many', 'many'])
    }
  })

  it('uses one/other for English', () => {
    expect(pluralCategory('en', 1)).toBe('one')
    expect(pluralCategory('en', 0)).toBe('other')
    expect(pluralCategory('en', 7)).toBe('other')
  })
})

// With sign-in switched off there is no account to follow, and the choice has
// to survive a reload in this browser.
describe('the language of a browser without an account', () => {
  function fakeStorage(initial = {}) {
    const entries = { ...initial }
    return {
      getItem: key => (key in entries ? entries[key] : null),
      setItem: (key, value) => { entries[key] = String(value) },
    }
  }

  afterEach(() => {
    vi.unstubAllGlobals()
    setLocale('en')
  })

  it('keeps the choice for the next visit', () => {
    const storage = fakeStorage()
    vi.stubGlobal('localStorage', storage)

    rememberLocale('ru')

    expect(storage.getItem(LOCALE_STORAGE_KEY)).toBe('ru')
    expect(storedLocale()).toBe('ru')
  })

  it('reads nothing out of storage that it cannot speak', () => {
    vi.stubGlobal('localStorage', fakeStorage({ [LOCALE_STORAGE_KEY]: 'kl-GL' }))

    expect(storedLocale()).toBeNull()
  })

  it('survives storage being unavailable', () => {
    // A private window: every access throws, and neither reading nor writing
    // may take the interface down with it.
    vi.stubGlobal('localStorage', {
      getItem: () => { throw new Error('blocked') },
      setItem: () => { throw new Error('blocked') },
    })

    expect(storedLocale()).toBeNull()
    expect(() => rememberLocale('uk')).not.toThrow()
  })
})