mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
7dcc509a54
Traditional Chinese cannot reuse the `%two_letters_code%` filename because it would collide with Simplified under `zh.json`, so `crowdin.yml` maps `zh-TW` to a `zh-Hant` catalog and locale matching resolves by script subtag before falling back to the base language. Every other new locale keeps its bare two-letter ID. Twenty-five entries outgrew the old menus. The mobile submenu inherited `overflow-hidden` with no max-height, so the last nine languages were unreachable on a phone, and the list was ordered by ISO code while displaying endonyms, which reads as unsorted. Both header menus now render one LanguagePicker that searches the native name, the English name and the locale code, groups the browser's own languages as suggestions, and orders the rest with a collator on the string it actually shows. Mobile opens a sheet instead of a nested flyout, since a flyout is the wrong shape for touch regardless of height. Settings gains the Appearance language section its copy has anticipated since the keys were added. Tighten the Crowdin progress chart, which the longer language list had stretched to 810px tall.
119 lines
4.5 KiB
TypeScript
119 lines
4.5 KiB
TypeScript
import { mount } from '@vue/test-utils'
|
|
import { reactive } from 'vue'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import LanguagePicker from '../LanguagePicker.vue'
|
|
|
|
const localeStore = reactive({ locale: 'en' })
|
|
const matchSupportedLocale = vi.fn<(candidates: readonly string[]) => string | null>(() => null)
|
|
|
|
vi.mock('@/stores/locale', () => ({
|
|
useLocaleStore: () => localeStore,
|
|
matchSupportedLocale: (candidates: readonly string[]) => matchSupportedLocale(candidates),
|
|
}))
|
|
|
|
function setBrowserLanguages(languages: string[]) {
|
|
Object.defineProperty(window.navigator, 'languages', { value: languages, configurable: true })
|
|
}
|
|
|
|
/** Row labels for one heading, so assertions do not depend on the other group's contents. */
|
|
function groupLabels(wrapper: ReturnType<typeof mount>, heading: string): string[] {
|
|
const group = wrapper.findAll('div.pb-1').find((candidate) => candidate.find('p')?.text() === heading)
|
|
if (!group) return []
|
|
return group.findAll('button').map((button) => button.find('span[lang]').text())
|
|
}
|
|
|
|
describe('LanguagePicker', () => {
|
|
beforeEach(() => {
|
|
localeStore.locale = 'en'
|
|
matchSupportedLocale.mockReset()
|
|
matchSupportedLocale.mockReturnValue(null)
|
|
setBrowserLanguages(['en-US'])
|
|
})
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
it('sorts every language by the name it actually renders, not by locale code', () => {
|
|
const labels = groupLabels(mount(LanguagePicker), 'All languages')
|
|
|
|
// Ordering by code would put Suomi between Español and Français, and Bahasa
|
|
// Indonesia between Magyar and Italiano, which reads as unsorted to a user.
|
|
expect(labels.slice(0, 6)).toEqual(['Bahasa Indonesia', 'Čeština', 'Dansk', 'Deutsch', 'English', 'Español'])
|
|
expect(labels).toHaveLength(25)
|
|
})
|
|
|
|
it('pairs each native name with its English name', () => {
|
|
const wrapper = mount(LanguagePicker)
|
|
const german = wrapper.findAll('button').find((button) => button.find('span[lang]').text() === 'Deutsch')
|
|
|
|
expect(german?.text()).toContain('German')
|
|
expect(german?.find('span[lang]').attributes('lang')).toBe('de')
|
|
})
|
|
|
|
it('distinguishes the two Chinese catalogs by script rather than by bare language', () => {
|
|
const wrapper = mount(LanguagePicker)
|
|
const texts = wrapper.findAll('button').map((button) => button.text())
|
|
|
|
expect(texts.some((text) => text.includes('简体中文') && text.includes('Simplified Chinese'))).toBe(true)
|
|
expect(texts.some((text) => text.includes('繁體中文') && text.includes('Traditional Chinese'))).toBe(true)
|
|
})
|
|
|
|
it('suggests the current locale plus the browser languages it can match', () => {
|
|
localeStore.locale = 'de'
|
|
setBrowserLanguages(['fr-FR', 'ja-JP'])
|
|
matchSupportedLocale.mockImplementation((candidates) => {
|
|
const [candidate] = candidates
|
|
if (candidate === 'fr-FR') return 'fr'
|
|
if (candidate === 'ja-JP') return 'ja'
|
|
return null
|
|
})
|
|
|
|
expect(groupLabels(mount(LanguagePicker), 'Suggested')).toEqual(['Deutsch', 'Français', '日本語'])
|
|
})
|
|
|
|
it('never repeats the current locale in the suggestions', () => {
|
|
localeStore.locale = 'fr'
|
|
setBrowserLanguages(['fr-FR', 'fr-CA'])
|
|
matchSupportedLocale.mockReturnValue('fr')
|
|
|
|
expect(groupLabels(mount(LanguagePicker), 'Suggested')).toEqual(['Français'])
|
|
})
|
|
|
|
it('filters on the native name, the English name, and the locale code', async () => {
|
|
const wrapper = mount(LanguagePicker)
|
|
const input = wrapper.find('input')
|
|
|
|
for (const [query, expected] of [
|
|
['deuts', 'Deutsch'],
|
|
['germ', 'Deutsch'],
|
|
['ko', '한국어'],
|
|
] as const) {
|
|
await input.setValue(query)
|
|
expect(groupLabels(wrapper, 'Results')).toContain(expected)
|
|
}
|
|
})
|
|
|
|
it('reports an empty search instead of rendering an empty list', async () => {
|
|
const wrapper = mount(LanguagePicker)
|
|
|
|
await wrapper.find('input').setValue('klingon')
|
|
|
|
expect(wrapper.findAll('button')).toHaveLength(0)
|
|
expect(wrapper.text()).toContain('No languages match "klingon"')
|
|
})
|
|
|
|
it('marks the active language and emits the one that was chosen', async () => {
|
|
localeStore.locale = 'pl'
|
|
const wrapper = mount(LanguagePicker)
|
|
|
|
const active = wrapper.findAll('button').filter((button) => button.attributes('aria-current') === 'true')
|
|
expect(active.every((button) => button.find('span[lang]').text() === 'Polski')).toBe(true)
|
|
|
|
const italian = wrapper.findAll('button').find((button) => button.find('span[lang]').text() === 'Italiano')
|
|
await italian!.trigger('click')
|
|
|
|
expect(wrapper.emitted('select')?.at(-1)).toEqual(['it'])
|
|
})
|
|
})
|