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:
Jakub Zych
2026-10-16 11:48:39 +02:00
parent 119f1f8d54
commit f0ab26b93f
9 changed files with 416 additions and 7 deletions

View File

@@ -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)')
})
})

View File

@@ -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')
})
})

View File

@@ -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()
})
})
})