feat(SPA): i18n internationzalization, english and french
This commit is contained in:
1 parent
7f1e8e572d
commit
721311a643
88 files changed
+4438
-1148
No files matched your search
@@ -0,0 +1,769 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import ui from '@nuxt/ui/vue-plugin'
|
||||
import App from '@/App.vue'
|
||||
import LanguagePicker from '@/components/LanguagePicker.vue'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import UserMenu from '@/components/UserMenu.vue'
|
||||
import AccountMenuSheet from '@/components/AccountMenuSheet.vue'
|
||||
import { useApi, ApiError } from '@/composables/useApi'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { useColorMode } from '@/composables/useColorMode'
|
||||
import router from '@/router'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import { initializeLocale, locale, setLocale, t } from './index'
|
||||
import { messages } from './messages'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
|
||||
|
||||
vi.mock('@/composables/useApi', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useApi: vi.fn(),
|
||||
authRedirectPending: ref(false),
|
||||
ApiError: class extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public retryAfter?: number,
|
||||
public body?: string,
|
||||
) {
|
||||
super('API failure')
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
const request = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
setViewportWidth(DESKTOP_WIDTH)
|
||||
setActivePinia(createPinia())
|
||||
useSessionStore().status = 'ready'
|
||||
request.mockReset().mockResolvedValue([])
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
request,
|
||||
getMe: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as ReturnType<typeof useApi>)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
localStorage.clear()
|
||||
initializeLocale()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
async function mountRoute(path: string) {
|
||||
const session = useSessionStore()
|
||||
session.session =
|
||||
path === '/login'
|
||||
? null
|
||||
: {
|
||||
id: 1,
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
displayName: 'Marta',
|
||||
email: 'marta@example.com',
|
||||
role: 'owner',
|
||||
}
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
wrapper = mount(App, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function trigger() {
|
||||
return document.body.querySelector<HTMLButtonElement>(
|
||||
'[role="combobox"][aria-label="Language"], [role="combobox"][aria-label="Langue"]',
|
||||
)!
|
||||
}
|
||||
|
||||
function key(element: Element, value: string) {
|
||||
element.dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: value,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('Translate the entire interface', () => {
|
||||
it('provides nonempty equivalent interpolation parameters for every English and French key', () => {
|
||||
const placeholders = (text: string) =>
|
||||
[...text.matchAll(/\{\d+\}/g)].map((match) => match[0]).sort()
|
||||
expect(
|
||||
Object.entries(messages).filter(
|
||||
([english, french]) =>
|
||||
!french.trim() ||
|
||||
JSON.stringify(placeholders(english)) !== JSON.stringify(placeholders(french)),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
it('formats French dates, decimal sizes and plural counts', () => {
|
||||
setLocale('fr')
|
||||
expect([
|
||||
formatAbsoluteDateFromIso('2025-02-14T12:00:00Z'),
|
||||
formatSize(1572864),
|
||||
pluralArticles(1234),
|
||||
]).toEqual(['14 févr. 2025', '1,5 Mo', '1 234 articles'])
|
||||
})
|
||||
it('falls back to the English key for an unavailable French message', () => {
|
||||
setLocale('fr')
|
||||
expect(t('Unavailable message' as keyof typeof messages)).toBe('Unavailable message')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Switch language without losing work', () => {
|
||||
it('retranslates added and retried bulk failures while preserving filenames and retry targets', async () => {
|
||||
let items = ['north-wall.jpg', 'kitchen-after.webp', 'kitchen-before.webp'].map(
|
||||
(filename, index) => ({
|
||||
id: index + 1,
|
||||
filename,
|
||||
url: `/media/${filename}`,
|
||||
size: 1024,
|
||||
content_type: 'image/jpeg',
|
||||
width: 100,
|
||||
height: 100,
|
||||
sha256: filename,
|
||||
uploaded_by: 'Marta',
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
used_in: 0,
|
||||
duplicate: false,
|
||||
}),
|
||||
)
|
||||
let retry = false
|
||||
request.mockImplementation(async (path: string, options?: RequestInit) => {
|
||||
if (options?.method === 'DELETE') {
|
||||
const id = Number(path.substring(path.lastIndexOf('/') + 1))
|
||||
if (id === 3 || (id === 2 && !retry)) throw new ApiError(500)
|
||||
items = items.filter((item) => item.id !== id)
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
items,
|
||||
next_cursor: null,
|
||||
counts: { all: items.length, unused: items.length, duplicates: 0 },
|
||||
}
|
||||
})
|
||||
await mountRoute('/media')
|
||||
for (const item of items) {
|
||||
document.body
|
||||
.querySelector<HTMLElement>(
|
||||
`[role="checkbox"][aria-label="Select ${item.filename}"]`,
|
||||
)!
|
||||
.click()
|
||||
}
|
||||
await flushPromises()
|
||||
document.body.querySelector<HTMLButtonElement>('[data-testid="bulk-delete"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
|
||||
.find((button) => button.textContent?.trim() === 'Delete 3 images')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
const snapshots: boolean[] = []
|
||||
async function captureBothWays(english: string, french: string) {
|
||||
for (const [language, description, label] of [
|
||||
['en', english, 'Retry'],
|
||||
['fr', french, 'Réessayer'],
|
||||
['en', english, 'Retry'],
|
||||
] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(
|
||||
document.body.textContent!.includes(description) &&
|
||||
Array.from(document.body.querySelectorAll('button')).some(
|
||||
(button) => button.textContent?.trim() === label,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
await captureBothWays(
|
||||
'1 image was deleted. kitchen-after.webp and kitchen-before.webp are still in your library. Try again.',
|
||||
'1 image a été supprimée. kitchen-after.webp et kitchen-before.webp sont toujours dans votre bibliothèque. Réessayez.',
|
||||
)
|
||||
retry = true
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((button) => button.textContent?.trim() === 'Retry')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
await captureBothWays(
|
||||
'2 images were deleted. kitchen-before.webp is still in your library. Try again.',
|
||||
'2 images ont été supprimées. kitchen-before.webp est toujours dans votre bibliothèque. Réessayez.',
|
||||
)
|
||||
expect({
|
||||
snapshots,
|
||||
deleted: request.mock.calls
|
||||
.filter(([, options]) => options?.method === 'DELETE')
|
||||
.map(([path]) => path),
|
||||
}).toEqual({
|
||||
snapshots: [true, true, true, true, true, true],
|
||||
deleted: ['/v1/media/1', '/v1/media/2', '/v1/media/3', '/v1/media/2', '/v1/media/3'],
|
||||
})
|
||||
})
|
||||
it.each(['fallback', 'French catalog error'])(
|
||||
'updates an active confirmation %s when switching both ways',
|
||||
async (failure) => {
|
||||
wrapper = mount(ConfirmDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
title: 'Delete',
|
||||
description: 'Confirm deletion',
|
||||
confirmLabel: 'Delete',
|
||||
cancelLabel: 'Cancel',
|
||||
pendingLabel: 'Deleting',
|
||||
tone: 'danger',
|
||||
action: async () => {
|
||||
if (failure === 'French catalog error') {
|
||||
setLocale('fr')
|
||||
throw new Error(t('Something went wrong. Try again.'))
|
||||
}
|
||||
throw null
|
||||
},
|
||||
},
|
||||
global: { plugins: [ui] },
|
||||
})
|
||||
await flushPromises()
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
|
||||
)
|
||||
.find((button) => button.textContent?.trim() === 'Delete')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
setLocale('en')
|
||||
await flushPromises()
|
||||
const english = document.body.querySelector('[role="alert"]')?.textContent?.trim()
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
const french = document.body.querySelector('[role="alert"]')?.textContent?.trim()
|
||||
setLocale('en')
|
||||
await flushPromises()
|
||||
expect([
|
||||
english,
|
||||
french,
|
||||
document.body.querySelector('[role="alert"]')?.textContent?.trim(),
|
||||
]).toEqual([
|
||||
'Something went wrong. Try again.',
|
||||
'Une erreur est survenue. Réessayez.',
|
||||
'Something went wrong. Try again.',
|
||||
])
|
||||
},
|
||||
)
|
||||
it('switches both ways while preserving profile draft, route, session, theme and request count', async () => {
|
||||
await mountRoute('/profile')
|
||||
const input = wrapper!.find('input[autocomplete="off"]')
|
||||
await input.setValue('Unsaved name')
|
||||
const session = JSON.stringify(useSessionStore().session)
|
||||
const theme = useColorMode().preference
|
||||
const count = request.mock.calls.length
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
const french = document.body.textContent!.includes('Nom d’affichage')
|
||||
setLocale('en')
|
||||
await flushPromises()
|
||||
expect({
|
||||
french,
|
||||
draft: (input.element as HTMLInputElement).value,
|
||||
path: router.currentRoute.value.path,
|
||||
session: JSON.stringify(useSessionStore().session),
|
||||
theme: useColorMode().preference,
|
||||
requests: request.mock.calls.length,
|
||||
english: document.body.textContent!.includes('Display name'),
|
||||
}).toEqual({
|
||||
french: true,
|
||||
draft: 'Unsaved name',
|
||||
path: '/profile',
|
||||
session,
|
||||
theme,
|
||||
requests: count,
|
||||
english: true,
|
||||
})
|
||||
})
|
||||
it('localizes profile save failure and retains the user draft', async () => {
|
||||
setLocale('fr')
|
||||
await mountRoute('/profile')
|
||||
await wrapper!.find('input[autocomplete="off"]').setValue('Name stays')
|
||||
request.mockRejectedValueOnce(new ApiError(500, undefined, 'private backend error'))
|
||||
await wrapper!.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect({
|
||||
alert: document.body.querySelector('[role="alert"]')?.textContent?.trim(),
|
||||
draft: (wrapper!.find('input[autocomplete="off"]').element as HTMLInputElement).value,
|
||||
}).toEqual({
|
||||
alert: 'Impossible d’enregistrer votre nom d’affichage. Votre texte est conservé. Réessayez dans un instant.',
|
||||
draft: 'Name stays',
|
||||
})
|
||||
})
|
||||
it('updates an already-visible authentication alert and its validation', async () => {
|
||||
await mountRoute('/login?reason=expired')
|
||||
await wrapper!.find('form').trigger('submit')
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
expect([
|
||||
document.body.textContent!.includes('Votre session a expiré'),
|
||||
document.body.textContent!.includes('Utilisez au moins 3 caractères.'),
|
||||
document.body.textContent!.includes('Saisissez votre mot de passe.'),
|
||||
]).toEqual([true, true, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe('Operate the picker accessibly', () => {
|
||||
it.each([false, true])(
|
||||
'supports keyboard selection and focus return with mobile=%s',
|
||||
async (mobile) => {
|
||||
wrapper = mount(LanguagePicker, {
|
||||
attachTo: document.body,
|
||||
props: { mobile },
|
||||
global: { plugins: [ui] },
|
||||
})
|
||||
trigger().focus()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
const options = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="option"]'),
|
||||
)
|
||||
const selected = options
|
||||
.find((option) => option.textContent!.includes('English'))
|
||||
?.getAttribute('aria-selected')
|
||||
key(document.activeElement!, 'End')
|
||||
await flushPromises()
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
key(document.activeElement!, 'Enter')
|
||||
await flushPromises()
|
||||
expect({
|
||||
options: options.map((option) => option.textContent!.trim()),
|
||||
selected,
|
||||
locale: locale.value,
|
||||
closed: !document.body.querySelector('[role="listbox"]'),
|
||||
focus: document.activeElement === trigger(),
|
||||
}).toEqual({
|
||||
options: ['English', 'Français'],
|
||||
selected: 'true',
|
||||
locale: 'fr',
|
||||
closed: true,
|
||||
focus: true,
|
||||
})
|
||||
},
|
||||
)
|
||||
it('dismisses with Escape and preserves the language', async () => {
|
||||
wrapper = mount(LanguagePicker, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui] },
|
||||
})
|
||||
trigger().focus()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
key(document.activeElement!, 'Escape')
|
||||
await flushPromises()
|
||||
expect([
|
||||
locale.value,
|
||||
!document.body.querySelector('[role="listbox"]'),
|
||||
document.activeElement === trigger(),
|
||||
]).toEqual(['en', true, true])
|
||||
})
|
||||
it.each(['desktop', 'mobile'])('offers the picker in the %s account menu', async (menu) => {
|
||||
useSessionStore().session = {
|
||||
verified: true,
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
}
|
||||
useSessionStore().status = 'ready'
|
||||
await router.push('/profile')
|
||||
wrapper =
|
||||
menu === 'desktop'
|
||||
? mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', collapsed: false },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
: mount(AccountMenuSheet, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', open: true },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
if (menu === 'desktop')
|
||||
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
|
||||
await flushPromises()
|
||||
const profileEntry = Array.from(
|
||||
document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'),
|
||||
).find((item) => item.textContent?.trim() === 'Profile')!
|
||||
profileEntry.focus()
|
||||
key(profileEntry, menu === 'desktop' ? 'End' : 'ArrowDown')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
const reachedControl = document.activeElement === trigger()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
key(document.activeElement!, 'End')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
key(document.activeElement!, 'Enter')
|
||||
await flushPromises()
|
||||
expect([
|
||||
reachedControl,
|
||||
locale.value,
|
||||
document.activeElement === trigger(),
|
||||
trigger().textContent?.includes('Langue'),
|
||||
]).toEqual([true, 'fr', true, true])
|
||||
})
|
||||
it('retains French after logout and renders the public authentication screen in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountRoute('/profile')
|
||||
document.body.querySelector<HTMLButtonElement>('button[title="Compte"]')!.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((item) => item.textContent?.trim() === 'Se déconnecter')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect([
|
||||
router.currentRoute.value.path,
|
||||
locale.value,
|
||||
localStorage.getItem('renewcms-language'),
|
||||
document.body.textContent!.includes('Se connecter'),
|
||||
]).toEqual(['/login', 'fr', 'fr', true])
|
||||
})
|
||||
it('updates an existing success notification without translating the saved account name', async () => {
|
||||
await mountRoute('/profile')
|
||||
await wrapper!.find('input[autocomplete="off"]').setValue('Log in')
|
||||
request.mockResolvedValueOnce({ display_name: 'Log in' })
|
||||
await wrapper!.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
expect([
|
||||
document.body.textContent!.includes('Nom d’affichage enregistré'),
|
||||
document.body.textContent!.includes(
|
||||
'Vous apparaissez désormais sous le nom Log in dans RenewCMS et les e-mails.',
|
||||
),
|
||||
]).toEqual([true, true])
|
||||
})
|
||||
it('updates an already-open confirmation dialog', async () => {
|
||||
await mountRoute('/profile')
|
||||
await wrapper!.find('input[autocomplete="off"]').setValue('Unsaved')
|
||||
const navigation = router.push('/home')
|
||||
await flushPromises()
|
||||
setLocale('fr')
|
||||
await flushPromises()
|
||||
const translated = document.body
|
||||
.querySelector('[role="alertdialog"]')
|
||||
?.textContent?.includes('Quitter sans enregistrer ?')
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'))
|
||||
.find((button) => button.textContent?.trim() === 'Abandonner les modifications')!
|
||||
.click()
|
||||
await navigation
|
||||
expect(translated).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Hardener localization outcomes', () => {
|
||||
it.each([false, true])(
|
||||
'updates not-found breadcrumbs round-trip with authenticated=%s',
|
||||
async (authenticated) => {
|
||||
const { default: NotFoundView } = await import('@/views/NotFoundView.vue')
|
||||
useSessionStore().session = authenticated
|
||||
? { verified: true, username: 'marta', displayName: '' }
|
||||
: null
|
||||
wrapper = mount(NotFoundView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(
|
||||
document.body.querySelector('[role="alert"] nav')?.textContent?.trim(),
|
||||
)
|
||||
}
|
||||
expect(snapshots).toEqual(['Not found', 'Introuvable', 'Not found'])
|
||||
},
|
||||
)
|
||||
it('updates the default error breadcrumb round-trip', async () => {
|
||||
const { default: ErrorView } = await import('@/components/ErrorView.vue')
|
||||
wrapper = mount(ErrorView, {
|
||||
attachTo: document.body,
|
||||
props: { kind: 'error' },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(wrapper.find('nav').text())
|
||||
}
|
||||
expect(snapshots).toEqual(['Home', 'Accueil', 'Home'])
|
||||
})
|
||||
it('focuses the invalid username in French', async () => {
|
||||
setLocale('fr')
|
||||
await mountRoute('/login')
|
||||
await wrapper!.find('form').trigger('submit')
|
||||
await flushPromises()
|
||||
expect(document.activeElement).toBe(wrapper!.find('input[autocomplete="username"]').element)
|
||||
})
|
||||
it('focuses article search after clearing French no-results', async () => {
|
||||
request.mockResolvedValue([
|
||||
{
|
||||
id: 12,
|
||||
title: 'Data title',
|
||||
body: '',
|
||||
is_online: false,
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
},
|
||||
])
|
||||
setLocale('fr')
|
||||
await mountRoute('/article?q=absent')
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((b) => b.textContent?.trim() === 'Effacer la recherche')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect([
|
||||
document.activeElement === document.body.querySelector('input[type="search"]'),
|
||||
router.currentRoute.value.query.q,
|
||||
]).toEqual([true, undefined])
|
||||
})
|
||||
it('copies the unchanged integration URL with French feedback and protocol examples', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined)
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
|
||||
setLocale('fr')
|
||||
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
|
||||
const { default: IntegrationView } = await import('@/views/IntegrationView.vue')
|
||||
wrapper = mount(IntegrationView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((b) => b.textContent?.trim() === 'Copier')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
expect([
|
||||
writeText.mock.calls[0]?.[0],
|
||||
document.body.textContent?.includes('Copié'),
|
||||
document.body.textContent?.includes('/v1/article/'),
|
||||
document.body.textContent?.includes('GET'),
|
||||
]).toEqual([window.location.origin, true, true, true])
|
||||
await vi.waitFor(() =>
|
||||
expect(document.body.textContent).toContain(
|
||||
`const res = await fetch('${window.location.origin}/v1/article/')`,
|
||||
),
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
it.each(['desktop', 'mobile'] as const)(
|
||||
'dismisses nested picker with Escape and outside press in %s menu',
|
||||
async (menu) => {
|
||||
useSessionStore().session = { verified: true, username: 'marta', displayName: '' }
|
||||
useSessionStore().status = 'ready'
|
||||
await router.push('/profile')
|
||||
wrapper =
|
||||
menu === 'desktop'
|
||||
? mount(UserMenu, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', collapsed: false },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
: mount(AccountMenuSheet, {
|
||||
attachTo: document.body,
|
||||
props: { username: 'marta', open: true },
|
||||
global: { plugins: [ui, router] },
|
||||
})
|
||||
if (menu === 'desktop')
|
||||
document.body.querySelector<HTMLButtonElement>('button[title="Account"]')!.click()
|
||||
await flushPromises()
|
||||
trigger().focus()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
key(document.activeElement!, 'Escape')
|
||||
await flushPromises()
|
||||
const escape = [
|
||||
locale.value,
|
||||
!document.body.querySelector('[role="listbox"]'),
|
||||
document.activeElement === trigger(),
|
||||
]
|
||||
trigger().focus()
|
||||
key(trigger(), 'ArrowDown')
|
||||
await flushPromises()
|
||||
const outside = document.createElement('button')
|
||||
outside.textContent = 'Outside'
|
||||
document.body.append(outside)
|
||||
outside.dispatchEvent(
|
||||
Object.assign(new MouseEvent('pointerdown', { bubbles: true, button: 0 }), {
|
||||
pointerType: 'mouse',
|
||||
}),
|
||||
)
|
||||
outside.click()
|
||||
await flushPromises()
|
||||
expect([
|
||||
escape,
|
||||
locale.value,
|
||||
!document.body.querySelector('[role="listbox"]'),
|
||||
document.activeElement === trigger(),
|
||||
]).toEqual([['en', true, true], 'en', true, true])
|
||||
},
|
||||
)
|
||||
it.each(['/article', '/article/12/edit'])(
|
||||
'updates already-open delete descriptions round-trip from %s preserving title and image sentence',
|
||||
async (path) => {
|
||||
const article = {
|
||||
id: 12,
|
||||
title: 'Log in',
|
||||
body: '<p><img src="/static/uploadedImages/photo.png"></p>',
|
||||
is_online: false,
|
||||
images: [],
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
}
|
||||
request.mockImplementation(async (url: string) =>
|
||||
url === '/v1/article/' ? [article] : article,
|
||||
)
|
||||
await mountRoute(path)
|
||||
if (path === '/article') {
|
||||
document.body
|
||||
.querySelector<HTMLButtonElement>('[aria-label="Actions for Log in"]')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
||||
.find((b) => b.textContent?.trim() === 'Delete…')!
|
||||
.click()
|
||||
} else
|
||||
Array.from(document.body.querySelectorAll<HTMLButtonElement>('button'))
|
||||
.find((b) => b.textContent?.trim() === 'Delete')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(document.body.querySelector('[role="alertdialog"]')?.textContent)
|
||||
}
|
||||
expect(
|
||||
snapshots.map((text) => [
|
||||
text?.includes('Log in'),
|
||||
text?.includes('will be deleted'),
|
||||
text?.includes('sera supprimé'),
|
||||
]),
|
||||
).toEqual([
|
||||
[true, true, false],
|
||||
[true, false, true],
|
||||
[true, true, false],
|
||||
])
|
||||
expect(snapshots[0]).toContain('Its images stay in your library.')
|
||||
expect(snapshots[1]).toContain('Ses images restent dans votre bibliothèque.')
|
||||
Array.from(
|
||||
document.body.querySelectorAll<HTMLButtonElement>('[role="alertdialog"] button'),
|
||||
)
|
||||
.find((b) => b.textContent?.trim() === 'Cancel')!
|
||||
.click()
|
||||
await flushPromises()
|
||||
},
|
||||
)
|
||||
it('translates the editor mobile heading', async () => {
|
||||
request.mockResolvedValue({
|
||||
id: 12,
|
||||
title: 'Data title',
|
||||
body: '',
|
||||
images: [],
|
||||
is_online: false,
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
updated_at: '2025-02-14T12:00:00Z',
|
||||
})
|
||||
setViewportWidth(390)
|
||||
setLocale('fr')
|
||||
await mountRoute('/article/12/edit')
|
||||
expect(document.body.textContent).toContain('Modifier l’article')
|
||||
})
|
||||
it('updates a visible 429 upload alert round-trip', async () => {
|
||||
let upload: LimitedUpload | undefined
|
||||
const captureUpload = (instance: LimitedUpload) => {
|
||||
upload = instance
|
||||
}
|
||||
class LimitedUpload extends EventTarget {
|
||||
upload = new EventTarget()
|
||||
status = 0
|
||||
responseText = ''
|
||||
withCredentials = false
|
||||
open() {}
|
||||
send() {
|
||||
captureUpload(this)
|
||||
}
|
||||
abort() {}
|
||||
getResponseHeader() {
|
||||
return '42'
|
||||
}
|
||||
respond() {
|
||||
this.status = 429
|
||||
this.dispatchEvent(new Event('load'))
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('XMLHttpRequest', LimitedUpload)
|
||||
vi.stubGlobal('crypto', { subtle: { digest: async () => new Uint8Array(32).buffer } })
|
||||
request.mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
id: 1,
|
||||
filename: 'existing.jpg',
|
||||
url: '/media/existing.jpg',
|
||||
content_type: 'image/jpeg',
|
||||
size: 1024,
|
||||
width: 100,
|
||||
height: 100,
|
||||
created_at: '2025-02-14T12:00:00Z',
|
||||
used_in: 0,
|
||||
sha256: 'abcdef',
|
||||
uploaded_by: 'Marta',
|
||||
duplicate: false,
|
||||
},
|
||||
],
|
||||
next_cursor: null,
|
||||
counts: { all: 1, unused: 1, duplicates: 0 },
|
||||
})
|
||||
const listing = await request()
|
||||
request.mockImplementation(async (path: string) =>
|
||||
path.includes('?hash=')
|
||||
? { items: [], next_cursor: null, counts: { all: 0, unused: 0, duplicates: 0 } }
|
||||
: listing,
|
||||
)
|
||||
await mountRoute('/media')
|
||||
const input = document.body.querySelector<HTMLInputElement>('input[type="file"]')!
|
||||
Object.defineProperty(input, 'files', {
|
||||
value: [new File(['photo'], 'photo.jpg', { type: 'image/jpeg' })],
|
||||
configurable: true,
|
||||
})
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await vi.waitFor(() => expect(upload).toBeDefined())
|
||||
upload!.respond()
|
||||
await flushPromises()
|
||||
const snapshots = []
|
||||
for (const language of ['en', 'fr', 'en'] as const) {
|
||||
setLocale(language)
|
||||
await flushPromises()
|
||||
snapshots.push(
|
||||
Array.from(document.body.querySelectorAll('[role="status"]')).some(
|
||||
(e) =>
|
||||
e.textContent?.includes(
|
||||
language === 'fr' ? 'Trop d’importations' : 'Too many uploads',
|
||||
) &&
|
||||
e.textContent.includes(
|
||||
language === 'fr'
|
||||
? 'Pour votre sécurité, patientez avant d’importer à nouveau.'
|
||||
: 'For your security, wait a moment before uploading again.',
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
expect(snapshots).toEqual([true, true, true])
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user