Files
summercms/admin/tests/state/usePreferences.test.ts
Jakub Zych f0ab26b93f test(quick-261016-0fm): theme switch, language switcher and footer offset
- theme: light and dark ignore prefers-color-scheme, choices and guard
- i18n: ?locale= only with a preference, availableLocales from meta
- usePreferences: cache validation, cycle order and PUT bodies, revert
  with a danger toast, profile apply, setLocale save, cache and reload
- rail theme button, user menu language group, --shell-offset and the
  form save bar offset; fixture strings and resetState theme reset
2026-10-16 11:48:39 +02:00

203 lines
8.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { currentLocale } from '../../src/app/i18n'
import {
applyProfilePreferences,
applyTheme,
cachedLocale,
cachedTheme,
cycleTheme,
LOCALE_KEY,
localePreference,
nextTheme,
page,
setLocale,
setTheme,
THEME_KEY,
themeChoice,
usePreferences,
} from '../../src/state/usePreferences'
import { toasts } from '../../src/state/useToasts'
import { API, mockApi, profile, requestsTo, resetState } from '../helpers'
const saved = (theme: string, locale = '') => ({ body: { data: { theme, locale }, meta: {} } })
const failed = { status: 500, body: { error: { code: 'error', message: 'x', details: {} } } }
async function bodyOf(request: Request): Promise<unknown> {
return JSON.parse(await request.clone().text())
}
function withPreferences(theme: string, locale: string) {
return { ...profile.data, preferences: { theme, locale } }
}
beforeEach(() => {
resetState()
})
afterEach(() => {
vi.restoreAllMocks()
applyTheme('system')
document.documentElement.classList.remove('dark')
})
describe('usePreferences cache', () => {
it('uses the documented storage keys', () => {
expect(THEME_KEY).toBe('summer-admin.theme')
expect(LOCALE_KEY).toBe('summer-admin.locale')
})
it('cachedTheme accepts only a valid choice', () => {
expect(cachedTheme()).toBe('system')
window.localStorage.setItem(THEME_KEY, 'dark')
expect(cachedTheme()).toBe('dark')
window.localStorage.setItem(THEME_KEY, 'blue')
expect(cachedTheme()).toBe('system')
window.localStorage.setItem(THEME_KEY, '')
expect(cachedTheme()).toBe('system')
})
it('cachedLocale accepts only a short locale-shaped code', () => {
expect(cachedLocale()).toBe('')
for (const code of ['pl', 'en', 'pt-BR', 'zh_Hant']) {
window.localStorage.setItem(LOCALE_KEY, code)
expect(cachedLocale()).toBe(code)
}
for (const code of ['p', 'pl;drop', '../en', 'a'.repeat(17), 'en-', '<script>', 'en-US-extra']) {
window.localStorage.setItem(LOCALE_KEY, code)
expect(cachedLocale()).toBe('')
}
})
})
describe('theme', () => {
it('cycles system, light, dark, system', () => {
expect(nextTheme('system')).toBe('light')
expect(nextTheme('light')).toBe('dark')
expect(nextTheme('dark')).toBe('system')
})
it('applies at once, caches and PUTs each choice', async () => {
const calls = mockApi({ [`PUT ${API}/account/preferences`]: saved('light') })
await cycleTheme()
expect(themeChoice.value).toBe('light')
expect(document.documentElement.classList.contains('dark')).toBe(false)
expect(window.localStorage.getItem(THEME_KEY)).toBe('light')
await cycleTheme()
expect(themeChoice.value).toBe('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(window.localStorage.getItem(THEME_KEY)).toBe('dark')
await cycleTheme()
expect(themeChoice.value).toBe('system')
// system is the default and is not stored.
expect(window.localStorage.getItem(THEME_KEY)).toBeNull()
const puts = requestsTo(calls, 'PUT', `${API}/account/preferences`)
expect(await Promise.all(puts.map(bodyOf))).toEqual([{ theme: 'light' }, { theme: 'dark' }, { theme: 'system' }])
expect(toasts.value).toHaveLength(0)
})
it('reverts the theme and the cache and shows a danger toast when the save fails', async () => {
mockApi({ [`PUT ${API}/account/preferences`]: failed })
await setTheme('dark')
expect(themeChoice.value).toBe('system')
expect(document.documentElement.classList.contains('dark')).toBe(false)
expect(window.localStorage.getItem(THEME_KEY)).toBeNull()
expect(toasts.value).toHaveLength(1)
expect(toasts.value[0]).toMatchObject({ tone: 'danger', text: 'Nie udało się zapisać ustawienia.' })
})
it('reverts on a network failure too', async () => {
applyTheme('light')
window.localStorage.setItem(THEME_KEY, 'light')
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new TypeError('network'))
await setTheme('dark')
expect(themeChoice.value).toBe('light')
expect(window.localStorage.getItem(THEME_KEY)).toBe('light')
expect(toasts.value[0]?.tone).toBe('danger')
})
})
describe('applyProfilePreferences', () => {
it('applies and caches the stored theme', async () => {
const calls = mockApi({})
await applyProfilePreferences(withPreferences('dark', ''))
expect(themeChoice.value).toBe('dark')
expect(document.documentElement.classList.contains('dark')).toBe(true)
expect(window.localStorage.getItem(THEME_KEY)).toBe('dark')
expect(requestsTo(calls, 'GET', `${API}/lang`)).toHaveLength(0)
})
it('ignores an invalid theme and a missing preferences object', async () => {
applyTheme('light')
await applyProfilePreferences(withPreferences('blue', ''))
expect(themeChoice.value).toBe('light')
const { preferences: _omit, ...bare } = profile.data
await applyProfilePreferences(bare as never)
await applyProfilePreferences(null)
expect(themeChoice.value).toBe('light')
})
it('reloads the strings only when the stored locale differs', async () => {
const calls = mockApi({
[`GET ${API}/lang`]: { body: { data: { 'backend::lang.auth.title': { other: 'Welcome back' } }, meta: { locale: 'en', locales: ['en', 'pl'] } } },
})
expect(currentLocale.value).toBe('pl')
await applyProfilePreferences(withPreferences('system', 'pl'))
expect(requestsTo(calls, 'GET', `${API}/lang`)).toHaveLength(0)
expect(window.localStorage.getItem(LOCALE_KEY)).toBe('pl')
expect(localePreference.value).toBe('pl')
await applyProfilePreferences(withPreferences('system', 'en'))
const loads = requestsTo(calls, 'GET', `${API}/lang`)
expect(loads).toHaveLength(1)
expect(new URL(loads[0]!.url).searchParams.get('locale')).toBe('en')
expect(currentLocale.value).toBe('en')
expect(window.localStorage.getItem(LOCALE_KEY)).toBe('en')
await applyProfilePreferences(withPreferences('system', ''))
expect(requestsTo(calls, 'GET', `${API}/lang`)).toHaveLength(1)
expect(window.localStorage.getItem(LOCALE_KEY)).toBeNull()
expect(localePreference.value).toBe('')
})
})
describe('setLocale', () => {
it('PUTs the locale, caches it and reloads the page', async () => {
const reload = vi.spyOn(page, 'reload').mockImplementation(() => undefined)
const calls = mockApi({ [`PUT ${API}/account/preferences`]: saved('system', 'en') })
await setLocale('en')
const puts = requestsTo(calls, 'PUT', `${API}/account/preferences`)
expect(await Promise.all(puts.map(bodyOf))).toEqual([{ locale: 'en' }])
expect(window.localStorage.getItem(LOCALE_KEY)).toBe('en')
expect(localePreference.value).toBe('en')
expect(reload).toHaveBeenCalledTimes(1)
})
it('does nothing for the current preference', async () => {
const reload = vi.spyOn(page, 'reload').mockImplementation(() => undefined)
await applyProfilePreferences(withPreferences('system', 'pl'))
const calls = mockApi({ [`PUT ${API}/account/preferences`]: saved('system', 'pl') })
await setLocale('pl')
expect(requestsTo(calls, 'PUT', `${API}/account/preferences`)).toHaveLength(0)
expect(reload).not.toHaveBeenCalled()
})
it('shows a danger toast and neither caches nor reloads when the save fails', async () => {
const reload = vi.spyOn(page, 'reload').mockImplementation(() => undefined)
mockApi({ [`PUT ${API}/account/preferences`]: failed })
await setLocale('en')
expect(reload).not.toHaveBeenCalled()
expect(window.localStorage.getItem(LOCALE_KEY)).toBeNull()
expect(localePreference.value).toBe('')
expect(toasts.value[0]).toMatchObject({ tone: 'danger', text: 'Nie udało się zapisać ustawienia.' })
})
it('exposes the API through usePreferences', () => {
const prefs = usePreferences()
expect(prefs.theme).toBe(themeChoice)
expect(prefs.locale).toBe(localePreference)
expect(prefs.setTheme).toBe(setTheme)
expect(prefs.cycleTheme).toBe(cycleTheme)
expect(prefs.setLocale).toBe(setLocale)
})
})