Files
RenewCMS/web/src/i18n/spa.spec.ts
T

572 lines
18 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ConfirmDialog from '@/components/ConfirmDialog.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, setLocale, t } from './index'
import { messages } from './messages'
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
import { articleEnvelope, articleListUrl } from '@/views/__tests__/articleEnvelope'
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
}
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,
total: items.length,
page: 1,
size: 25,
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 new Promise((resolve) => setTimeout(resolve, 250))
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('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 () => {
const article = {
id: 12,
title: 'Data title',
body: '',
is_online: false,
created_at: '2025-02-14T12:00:00Z',
updated_at: '2025-02-14T12:00:00Z',
}
request.mockImplementation(async (url: string) =>
articleEnvelope([article], {
q: new URL(url, 'http://localhost').searchParams.get('q') ?? '',
}),
)
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])
document.body
.querySelector<HTMLButtonElement>('button[aria-label^="GET /v1/article/. "]')!
.click()
await vi.waitFor(() =>
expect(document.body.textContent).toContain(
`const res = await fetch('${window.location.origin}/v1/article/')`,
),
)
vi.unstubAllGlobals()
})
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 === articleListUrl() ? articleEnvelope([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,
},
],
total: 1,
page: 1,
size: 25,
counts: { all: 1, unused: 1, duplicates: 0 },
})
const listing = await request()
request.mockImplementation(async (path: string) =>
path.includes('?hash=')
? {
items: [],
total: 0,
page: 1,
size: 25,
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()
})
})