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
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
availableLocales,
|
||||
currentLocale,
|
||||
interpolate,
|
||||
loadStrings,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
t,
|
||||
tc,
|
||||
} from '../../src/app/i18n'
|
||||
import { API, mockApi, requestsTo } from '../helpers'
|
||||
import { API, mockApi, queryOf, requestsTo } from '../helpers'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -128,6 +129,29 @@ describe('loadStrings', () => {
|
||||
expect(currentLocale.value).toBe('en')
|
||||
})
|
||||
|
||||
it('sends the preferred locale as ?locale= and keeps meta.locales', async () => {
|
||||
const calls = mockApi({
|
||||
[`GET ${API}/lang`]: { body: { data: {}, meta: { locale: 'en', locales: ['en', 'pl'] } } },
|
||||
})
|
||||
await loadStrings('en')
|
||||
const [request] = requestsTo(calls, 'GET', `${API}/lang`)
|
||||
expect(queryOf(request!).get('locale')).toBe('en')
|
||||
expect(availableLocales.value).toEqual(['en', 'pl'])
|
||||
expect(currentLocale.value).toBe('en')
|
||||
})
|
||||
|
||||
it('sends no locale without a preference and follows a shorter meta.locales', async () => {
|
||||
const calls = mockApi({
|
||||
[`GET ${API}/lang`]: { body: { data: {}, meta: { locale: 'pl', locales: ['pl'] } } },
|
||||
})
|
||||
await loadStrings()
|
||||
await loadStrings('')
|
||||
for (const request of requestsTo(calls, 'GET', `${API}/lang`)) {
|
||||
expect(queryOf(request).has('locale')).toBe(false)
|
||||
}
|
||||
expect(availableLocales.value).toEqual(['pl'])
|
||||
})
|
||||
|
||||
it('keeps the current strings when the bundle cannot be loaded', async () => {
|
||||
mockApi({ [`GET ${API}/lang`]: { status: 500, body: { error: { code: 'server', message: 'x', details: {} } } } })
|
||||
await loadStrings()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { applyColorScheme, DARK_QUERY } from '../../src/app/theme'
|
||||
import { applyColorScheme, DARK_QUERY, isThemeChoice, THEME_CHOICES } from '../../src/app/theme'
|
||||
import { fakeMedia } from '../helpers'
|
||||
|
||||
afterEach(() => {
|
||||
document.documentElement.classList.remove('dark')
|
||||
})
|
||||
|
||||
describe('applyColorScheme (A6: system preference only)', () => {
|
||||
describe('applyColorScheme', () => {
|
||||
it('sets .dark from prefers-color-scheme and follows changes until unsubscribed', () => {
|
||||
const media = fakeMedia({ [DARK_QUERY]: true })
|
||||
const stop = applyColorScheme()
|
||||
@@ -31,4 +31,41 @@ describe('applyColorScheme (A6: system preference only)', () => {
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
expect(stop()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('light never sets .dark and ignores the media query', () => {
|
||||
const media = fakeMedia({ [DARK_QUERY]: true })
|
||||
document.documentElement.classList.add('dark')
|
||||
const stop = applyColorScheme(window, 'light')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
expect(media.listeners(DARK_QUERY)).toBe(0)
|
||||
media.change(DARK_QUERY, true)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(false)
|
||||
expect(stop()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('dark always sets .dark and ignores the media query', () => {
|
||||
const media = fakeMedia({ [DARK_QUERY]: false })
|
||||
const stop = applyColorScheme(window, 'dark')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
expect(media.listeners(DARK_QUERY)).toBe(0)
|
||||
media.change(DARK_QUERY, false)
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('dark works without matchMedia', () => {
|
||||
const win = { document } as unknown as Window
|
||||
applyColorScheme(win, 'dark')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('theme choices', () => {
|
||||
it('lists system, light and dark in cycle order and guards values', () => {
|
||||
expect(THEME_CHOICES).toEqual(['system', 'light', 'dark'])
|
||||
expect(isThemeChoice('dark')).toBe(true)
|
||||
expect(isThemeChoice('blue')).toBe(false)
|
||||
expect(isThemeChoice(null)).toBe(false)
|
||||
expect(isThemeChoice(1)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
21
admin/tests/fixtures/lang.json
vendored
21
admin/tests/fixtures/lang.json
vendored
@@ -567,6 +567,12 @@
|
||||
"backend::lang.myaccount.password_section": {
|
||||
"other": "Zmiana hasła"
|
||||
},
|
||||
"backend::lang.myaccount.preferences_failed": {
|
||||
"other": "Nie udało się zapisać ustawienia."
|
||||
},
|
||||
"backend::lang.myaccount.preferences_unavailable": {
|
||||
"other": "Nie można zapisać ustawienia, ponieważ zapisane metadane profilu nie są obiektem JSON."
|
||||
},
|
||||
"backend::lang.myaccount.profile": {
|
||||
"other": "Profil"
|
||||
},
|
||||
@@ -594,6 +600,9 @@
|
||||
"backend::lang.nav.expand": {
|
||||
"other": "Rozwiń menu"
|
||||
},
|
||||
"backend::lang.nav.language": {
|
||||
"other": "Język"
|
||||
},
|
||||
"backend::lang.nav.plugins": {
|
||||
"other": "Wtyczki"
|
||||
},
|
||||
@@ -603,6 +612,18 @@
|
||||
"backend::lang.nav.settings": {
|
||||
"other": "Ustawienia"
|
||||
},
|
||||
"backend::lang.nav.theme": {
|
||||
"other": "Motyw: :mode"
|
||||
},
|
||||
"backend::lang.nav.theme_dark": {
|
||||
"other": "Ciemny"
|
||||
},
|
||||
"backend::lang.nav.theme_light": {
|
||||
"other": "Jasny"
|
||||
},
|
||||
"backend::lang.nav.theme_system": {
|
||||
"other": "Systemowy"
|
||||
},
|
||||
"backend::lang.nav.user_menu": {
|
||||
"other": "Menu użytkownika"
|
||||
},
|
||||
|
||||
@@ -49,6 +49,15 @@ afterEach(() => {
|
||||
enableAutoUnmount(afterEach)
|
||||
|
||||
describe('form view', () => {
|
||||
it('starts the save bar at the sidebar edge so the rail stays uncovered', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
const footer = wrapper.get('footer')
|
||||
expect(footer.classes()).toContain('left-[var(--shell-offset,0px)]')
|
||||
expect(footer.classes()).toContain('right-0')
|
||||
expect(footer.classes()).not.toContain('inset-x-0')
|
||||
expect(footer.element.closest<HTMLElement>('[style*="--shell-offset"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('is the component of the record and create routes', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
|
||||
expect(wrapper.findComponent(FormView).exists()).toBe(true)
|
||||
|
||||
@@ -8,6 +8,7 @@ import App from '../src/App.vue'
|
||||
import { createAdminRouter } from '../src/app/router'
|
||||
import { setBundle } from '../src/app/i18n'
|
||||
import { clearUser, me } from '../src/state/useAuth'
|
||||
import { applyProfilePreferences } from '../src/state/usePreferences'
|
||||
import { clearRecordCrumb } from '../src/state/useBreadcrumbs'
|
||||
import { loadNavigation, setNavigation } from '../src/state/useNavigation'
|
||||
import { setSettings } from '../src/state/useSettings'
|
||||
@@ -158,7 +159,7 @@ export function fakeMedia(initial: Record<string, boolean>) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Module state back to a signed-out, Polish-strings start. */
|
||||
/** Module state back to a signed-out, Polish-strings, system-theme start. */
|
||||
export function resetState(): void {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
@@ -166,6 +167,9 @@ export function resetState(): void {
|
||||
clearToasts()
|
||||
clearRecordCrumb()
|
||||
setBundle(langFixture.data, 'pl')
|
||||
// Theme back to system and no stored admin language (runs synchronously:
|
||||
// an empty locale never awaits).
|
||||
void applyProfilePreferences(profile.data)
|
||||
window.localStorage.clear()
|
||||
// Re-reads the (cleared) preference and the desktop viewport of setup.ts,
|
||||
// so a narrow or collapsed sidebar never leaks into the next test.
|
||||
|
||||
@@ -116,4 +116,21 @@ describe('View site link (Phase 16.2 quick follow-up)', () => {
|
||||
expect(yaml).toMatch(new RegExp(`^nav:[\\s\\S]*?^ view_site: ${label}$`, 'm'))
|
||||
}
|
||||
})
|
||||
|
||||
it('exposes --shell-offset: the rail, plus the section panel when it shows', async () => {
|
||||
const wrapper = await mountShell('/acme/demo/widgets')
|
||||
const root = wrapper.element as HTMLElement
|
||||
expect(root.style.getPropertyValue('--shell-offset')).toBe('calc(var(--spacing-rail) + var(--spacing-panel))')
|
||||
setCollapsed(true)
|
||||
await flushPromises()
|
||||
expect(root.style.getPropertyValue('--shell-offset')).toBe('var(--spacing-rail)')
|
||||
setCollapsed(false)
|
||||
await flushPromises()
|
||||
expect(root.style.getPropertyValue('--shell-offset')).toBe('calc(var(--spacing-rail) + var(--spacing-panel))')
|
||||
})
|
||||
|
||||
it('offsets by the rail alone without a section panel', async () => {
|
||||
const wrapper = await mountShell('/settings')
|
||||
expect((wrapper.element as HTMLElement).style.getPropertyValue('--shell-offset')).toBe('var(--spacing-rail)')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { themeChoice } from '../../src/state/usePreferences'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import PluginRail from '../../src/components/shell/PluginRail.vue'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
import { setSettings } from '../../src/state/useSettings'
|
||||
import { NARROW_QUERY, SIDEBAR_KEY, setCollapsed, watchViewport } from '../../src/state/useSidebar'
|
||||
import { navigationFixture, settingsFixture } from '../fixtures/typed'
|
||||
import { fakeMedia, keydown, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
import { API, fakeMedia, keydown, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
|
||||
async function mountRail(path: string) {
|
||||
await signIn()
|
||||
@@ -147,4 +148,38 @@ describe('plugin rail', () => {
|
||||
await flushPromises()
|
||||
expect(document.querySelector('[data-section-flyout]')).toBeNull()
|
||||
})
|
||||
|
||||
it('cycles the theme from the top button, naming the current choice, and keeps Ustawienia', async () => {
|
||||
const calls = await signIn({
|
||||
[`PUT ${API}/account/preferences`]: { body: { data: { theme: 'light', locale: '' }, meta: {} } },
|
||||
})
|
||||
setNavigation(navigationFixture.data)
|
||||
setSettings(settingsFixture.list.data)
|
||||
const router = await routerAt('/acme/demo/widgets')
|
||||
const wrapper = mount(PluginRail, { global: { plugins: [router] }, attachTo: document.body })
|
||||
await flushPromises()
|
||||
|
||||
const button = wrapper.get('[data-theme-toggle]')
|
||||
expect(button.element.tagName).toBe('BUTTON')
|
||||
expect(button.attributes('type')).toBe('button')
|
||||
expect(button.attributes('aria-label')).toBe('Motyw: Systemowy')
|
||||
expect(button.attributes('title')).toBe('Motyw: Systemowy')
|
||||
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(themeChoice.value).toBe('light')
|
||||
expect(button.attributes('aria-label')).toBe('Motyw: Jasny')
|
||||
await button.trigger('click')
|
||||
await flushPromises()
|
||||
expect(button.attributes('aria-label')).toBe('Motyw: Ciemny')
|
||||
expect(document.documentElement.classList.contains('dark')).toBe(true)
|
||||
|
||||
const puts = requestsTo(calls, 'PUT', `${API}/account/preferences`)
|
||||
expect(await Promise.all(puts.map(async (request) => JSON.parse(await request.clone().text())))).toEqual([
|
||||
{ theme: 'light' },
|
||||
{ theme: 'dark' },
|
||||
])
|
||||
expect(wrapper.find('[data-rail-settings]').exists()).toBe(true)
|
||||
document.documentElement.classList.remove('dark')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
|
||||
import type { AdminProfile } from '../../src/api/types'
|
||||
import UserMenu from '../../src/components/shell/UserMenu.vue'
|
||||
import { loadStrings } from '../../src/app/i18n'
|
||||
import { currentUser } from '../../src/state/useAuth'
|
||||
import { API, profile, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
import { page } from '../../src/state/usePreferences'
|
||||
import { API, mockApi, profile, requestsTo, resetState, routerAt, signIn, wait } from '../helpers'
|
||||
import { langFixture } from '../fixtures/typed'
|
||||
|
||||
async function mountMenu(user: Partial<AdminProfile> | null) {
|
||||
let calls: Request[] = []
|
||||
@@ -128,4 +131,61 @@ describe('user menu', () => {
|
||||
expect(currentUser.value).toBeNull()
|
||||
expect(router.currentRoute.value.name).toBe('login')
|
||||
})
|
||||
|
||||
describe('language', () => {
|
||||
/** Loads the bundle with the given admin locales, then mounts the menu. */
|
||||
async function mountWithLocales(locales: string[], preference = '') {
|
||||
mockApi({
|
||||
[`GET ${API}/lang`]: { body: { data: langFixture.data, meta: { locale: 'pl', locales } } },
|
||||
})
|
||||
await loadStrings()
|
||||
vi.restoreAllMocks()
|
||||
return mountMenu({ preferences: { theme: 'system', locale: preference } })
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('lists every admin locale named in its own language, the current one checked', async () => {
|
||||
const { wrapper } = await mountWithLocales(['en', 'pl'])
|
||||
const content = await open(wrapper)
|
||||
expect(content.querySelector('[data-language-label]')?.textContent?.trim()).toBe('Język')
|
||||
const options = [...content.querySelectorAll<HTMLElement>('[data-locale-option]')]
|
||||
expect(options.map((option) => option.dataset.localeOption)).toEqual(['en', 'pl'])
|
||||
expect(options.map((option) => option.textContent?.trim())).toEqual(['English', 'Polski'])
|
||||
expect(options.map((option) => option.getAttribute('role'))).toEqual(['menuitemradio', 'menuitemradio'])
|
||||
expect(options.map((option) => option.getAttribute('aria-checked'))).toEqual(['false', 'true'])
|
||||
})
|
||||
|
||||
it('checks the stored preference over the bundle locale', async () => {
|
||||
const { wrapper } = await mountWithLocales(['en', 'pl'], 'en')
|
||||
const content = await open(wrapper)
|
||||
const en = content.querySelector<HTMLElement>('[data-locale-option="en"]')!
|
||||
expect(en.getAttribute('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
it('saves the chosen language and reloads the page', async () => {
|
||||
const { wrapper } = await mountWithLocales(['en', 'pl'])
|
||||
const reload = vi.spyOn(page, 'reload').mockImplementation(() => undefined)
|
||||
const calls = mockApi({
|
||||
[`PUT ${API}/account/preferences`]: { body: { data: { theme: 'system', locale: 'en' }, meta: {} } },
|
||||
})
|
||||
const content = await open(wrapper)
|
||||
content.querySelector<HTMLElement>('[data-locale-option="en"]')!.click()
|
||||
await flushPromises()
|
||||
const puts = requestsTo(calls, 'PUT', `${API}/account/preferences`)
|
||||
expect(puts).toHaveLength(1)
|
||||
expect(JSON.parse(await puts[0]!.clone().text())).toEqual({ locale: 'en' })
|
||||
expect(window.localStorage.getItem('summer-admin.locale')).toBe('en')
|
||||
expect(reload).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('has no language group with a single admin locale', async () => {
|
||||
const { wrapper } = await mountWithLocales(['pl'])
|
||||
const content = await open(wrapper)
|
||||
expect(content.querySelector('[data-locale-option]')).toBeNull()
|
||||
expect(content.querySelector('[data-language-label]')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
202
admin/tests/state/usePreferences.test.ts
Normal file
202
admin/tests/state/usePreferences.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user