From f0ab26b93f07f567f3fe6bffa51cbb9150141a18 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 16 Oct 2026 11:48:39 +0200 Subject: [PATCH] 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 --- admin/tests/app/i18n.test.ts | 26 ++- admin/tests/app/theme.test.ts | 41 ++++- admin/tests/fixtures/lang.json | 21 +++ admin/tests/form/FormView.test.ts | 9 + admin/tests/helpers.ts | 6 +- admin/tests/shell/AppShell.test.ts | 17 ++ admin/tests/shell/PluginRail.test.ts | 37 ++++- admin/tests/shell/UserMenu.test.ts | 64 ++++++- admin/tests/state/usePreferences.test.ts | 202 +++++++++++++++++++++++ 9 files changed, 416 insertions(+), 7 deletions(-) create mode 100644 admin/tests/state/usePreferences.test.ts diff --git a/admin/tests/app/i18n.test.ts b/admin/tests/app/i18n.test.ts index 5ac0903..978eb97 100644 --- a/admin/tests/app/i18n.test.ts +++ b/admin/tests/app/i18n.test.ts @@ -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() diff --git a/admin/tests/app/theme.test.ts b/admin/tests/app/theme.test.ts index 2d3bcef..07f3372 100644 --- a/admin/tests/app/theme.test.ts +++ b/admin/tests/app/theme.test.ts @@ -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) + }) }) diff --git a/admin/tests/fixtures/lang.json b/admin/tests/fixtures/lang.json index 4d2ffad..f156e0c 100644 --- a/admin/tests/fixtures/lang.json +++ b/admin/tests/fixtures/lang.json @@ -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" }, diff --git a/admin/tests/form/FormView.test.ts b/admin/tests/form/FormView.test.ts index a0f230d..49b15a9 100644 --- a/admin/tests/form/FormView.test.ts +++ b/admin/tests/form/FormView.test.ts @@ -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('[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) diff --git a/admin/tests/helpers.ts b/admin/tests/helpers.ts index 6ca5275..d09b67e 100644 --- a/admin/tests/helpers.ts +++ b/admin/tests/helpers.ts @@ -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) { } } -/** 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. diff --git a/admin/tests/shell/AppShell.test.ts b/admin/tests/shell/AppShell.test.ts index a27e7c1..9d2e149 100644 --- a/admin/tests/shell/AppShell.test.ts +++ b/admin/tests/shell/AppShell.test.ts @@ -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)') + }) }) diff --git a/admin/tests/shell/PluginRail.test.ts b/admin/tests/shell/PluginRail.test.ts index 65a9f95..08769d4 100644 --- a/admin/tests/shell/PluginRail.test.ts +++ b/admin/tests/shell/PluginRail.test.ts @@ -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') + }) }) diff --git a/admin/tests/shell/UserMenu.test.ts b/admin/tests/shell/UserMenu.test.ts index b7cb27c..7d19b37 100644 --- a/admin/tests/shell/UserMenu.test.ts +++ b/admin/tests/shell/UserMenu.test.ts @@ -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 | 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('[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('[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('[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() + }) + }) }) diff --git a/admin/tests/state/usePreferences.test.ts b/admin/tests/state/usePreferences.test.ts new file mode 100644 index 0000000..7c76d26 --- /dev/null +++ b/admin/tests/state/usePreferences.test.ts @@ -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 { + 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-', '