import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import router from '@/router' import { initializeLocale } from '@/i18n' import { MOBILE_WIDTH } from '@/components/__tests__/viewport' import { settle, text, unmountRoute } from './localizationKit' import { EDIT_ONLY, FULL, READ, buttonNamed, languageTrigger, menuRows, mountEditor, openLanguageMenu, rowFor, titleInput, } from './editorKit' import type { LanguageFixture } from './localizationData' vi.mock('@/composables/useApi', async (importOriginal) => { const actual = await importOriginal() return { ...actual, useApi: vi.fn() } }) beforeEach(() => initializeLocale()) afterEach(unmountRoute) const LANGUAGE_NAMES = ['English', 'Arabic', 'French', 'German'] function rowNames() { return menuRows().map((row) => row.textContent?.trim() ?? '') } function languageOf(row: string) { return LANGUAGE_NAMES.find((name) => row.startsWith(name)) ?? row } function languageRows(tags: string[]): LanguageFixture[] { return tags.map((lang) => ({ lang, versions: 1, published: 0 })) } describe('loc.l5.menu: The menu lists readable versions in API order', () => { it('opens a menu, not a listbox, with English, Arabic and French and no German', async () => { await mountEditor({ caps: EDIT_ONLY }) await openLanguageMenu() expect(document.body.querySelector('[role="menu"]')).not.toBeNull() expect(document.body.querySelector('[role="listbox"]')).toBeNull() expect(rowNames().map(languageOf)).toEqual(['English', 'Arabic', 'French']) }) it('names the control "Language: French. Switch version"', async () => { await mountEditor() expect(languageTrigger()?.getAttribute('aria-label')).toBe( 'Language: French. Switch version', ) }) it('shows each language name, code and publication badge, with the French row current', async () => { await mountEditor({ caps: EDIT_ONLY }) await openLanguageMenu() expect(rowNames()).toEqual([ expect.stringMatching(/^EnglishEN.*Published/), expect.stringMatching(/^ArabicAR.*Published/), expect.stringMatching(/^FrenchFR.*Draft/), ]) expect(rowFor('French')?.querySelector('[aria-current="true"]')).not.toBeNull() expect(rowFor('English')?.querySelector('[aria-current="true"]')).toBeNull() }) }) describe('loc.l5.two and loc.l5.many: Menu size', () => { it('shows two rows without a filter on a two-language site', async () => { await mountEditor({ caps: EDIT_ONLY, translations: ['en', 'fr'], languages: languageRows(['en', 'fr']), }) await openLanguageMenu() expect(menuRows()).toHaveLength(2) expect(document.body.querySelector('input[placeholder="Find a language"]')).toBeNull() }) it('shows all four rows without a filter', async () => { await mountEditor({ caps: EDIT_ONLY, translations: ['en', 'fr', 'de', 'ar'] }) await openLanguageMenu() expect(menuRows()).toHaveLength(4) expect(document.body.querySelector('input[placeholder="Find a language"]')).toBeNull() }) it('adds a filter field that narrows the rows by language name on a many-language site', async () => { const tags = ['en', 'fr', 'de', 'ar', 'es', 'it', 'ja', 'nl', 'pl', 'pt-BR'] await mountEditor({ caps: FULL, translations: ['en', 'fr', 'de', 'ar'], languages: languageRows(tags), }) await openLanguageMenu() const filter = document.body.querySelector( 'input[placeholder="Find a language"]', )! expect(menuRows().length).toBeGreaterThan(8) filter.value = 'port' filter.dispatchEvent(new Event('input', { bubbles: true })) await settle() expect(rowNames().map((name) => name.slice(0, 'Portuguese'.length))).toEqual(['Portuguese']) }) }) describe('loc.perm.*: Row tags and absent rows follow each sibling capabilities', () => { it('tags a read-only sibling as Read only and still opens it', async () => { await mountEditor({ siblingCaps: { ar: READ } }) await openLanguageMenu() expect(rowFor('Arabic')?.textContent).toContain('Read only') expect(rowFor('English')?.textContent).not.toContain('Read only') }) it('shows no German row, count or placeholder when German is not in translations', async () => { await mountEditor({ caps: EDIT_ONLY }) await openLanguageMenu() expect(rowFor('German')).toBeUndefined() expect(text()).not.toContain('German') }) it('keeps a disabled language listed and marks it as disabled', async () => { await mountEditor({ languageEnabled: { ar: false } }) await openLanguageMenu() expect(rowFor('Arabic')?.textContent).toContain('Disabled') }) }) describe('loc.l5b.withAdd, noAdd and roMenu: Add translation rows', () => { it('lists missing languages as Add translation rows when the caller can create', async () => { await mountEditor({ caps: FULL }) await openLanguageMenu() expect(rowFor('German')?.textContent).toContain('Add translation') }) it('offers nothing for missing languages without create_translation', async () => { await mountEditor({ caps: EDIT_ONLY }) await openLanguageMenu() expect(text()).not.toContain('Add translation') }) it('offers no Add translation row on a read-only version', async () => { await mountEditor({ caps: READ }) await openLanguageMenu() expect(text()).not.toContain('Add translation') }) it('opens the Add a translation dialog for the row and closes the menu', async () => { await mountEditor({ caps: FULL }) await openLanguageMenu() rowFor('German')!.click() await settle() expect(router.currentRoute.value.fullPath).toBe( '/article/12/edit?dialog=add-translation&lang=de', ) expect(document.body.querySelector('[role="menu"]')).toBeNull() expect(document.body.querySelector('[role="dialog"]')?.textContent).toContain( 'Add a translation', ) expect(titleInput().value).toBe('Couts de renovation') }) }) describe('The menu is keyboard operable', () => { it('opens on ArrowDown with focus on the current row', async () => { await mountEditor() languageTrigger()!.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }), ) await settle() expect(document.body.querySelector('[role="menu"]')).not.toBeNull() expect(document.activeElement?.textContent).toContain('French') }) it('focuses the current row when the sheet opens on a phone', async () => { await mountEditor({ width: MOBILE_WIDTH }) await openLanguageMenu() expect(document.activeElement?.textContent).toContain('French') }) it('closes on Escape', async () => { await mountEditor() await openLanguageMenu() document.activeElement?.dispatchEvent( new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }), ) await settle() expect(document.body.querySelector('[role="menu"]')).toBeNull() }) }) describe('A single-language site shows no language control', () => { it('hides the control, names no language and reads Publish', async () => { await mountEditor({ lang: 'en', translations: ['en'], languages: languageRows(['en']), online: { en: false }, }) expect(languageTrigger()).toBeNull() expect(buttonNamed('Publish')).toBeDefined() expect(text()).not.toContain('English') expect(titleInput().getAttribute('id')).not.toBeNull() }) }) describe('On a phone the menu opens as a sheet', () => { it('lists the same rows in a bottom sheet', async () => { await mountEditor({ width: MOBILE_WIDTH, caps: EDIT_ONLY }) languageTrigger()!.click() await settle() expect(rowNames().map(languageOf)).toEqual(['English', 'Arabic', 'French']) }) })