import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createApp, nextTick, type App, type ComputedRef } from 'vue' import { getI18nLocale, setI18nLocale } from '@/i18n' import Settings from '../Settings.vue' const meApiMock = vi.hoisted(() => ({ getProfile: vi.fn(), getPreferences: vi.fn(), listSessions: vi.fn(), updatePreferences: vi.fn(), })) const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })) vi.mock('@/api/me', () => ({ meApi: meApiMock })) vi.mock('@/api/auth', () => ({ authApi: { getRegistrationSettings: vi.fn().mockResolvedValue({ email_configured: false }) }, })) vi.mock('@/api/oauth', () => ({ oauthApi: {} })) vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ fetchCurrentUser: vi.fn(), logout: vi.fn() }), })) vi.mock('vue-router', () => ({ useRoute: () => ({ fullPath: '/dashboard/settings' }), useRouter: () => ({ replace: vi.fn() }), })) vi.mock('@/composables/useToast', () => ({ useToast: () => toastMock })) vi.mock('@/composables/useDarkMode', async () => { const { ref } = await import('vue') return { useDarkMode: () => ({ themeMode: ref('light'), setThemeMode: vi.fn() }) } }) vi.mock('@/utils/logger', () => ({ log: { error: vi.fn(), warn: vi.fn(), info: vi.fn() } })) interface SelectContext { value: ComputedRef select: (value: string) => void } // Keep the Select model/event contract while making its options directly // accessible without Radix's portal and pointer-event requirements in jsdom. vi.mock('@/components/ui/select.vue', async () => { const { computed, defineComponent, h, provide } = await import('vue') return { default: defineComponent({ props: { modelValue: String, open: Boolean }, emits: ['update:modelValue', 'update:open'], setup(props, { emit, slots }) { provide('settings-test-select', { value: computed(() => props.modelValue), select: value => emit('update:modelValue', value), }) return () => h('div', { 'data-select-value': props.modelValue }, slots.default?.()) }, }), } }) vi.mock('@/components/ui/select-trigger.vue', async () => { const { defineComponent, h } = await import('vue') return { default: defineComponent({ setup: (_, { slots }) => () => h('button', { type: 'button' }, slots.default?.()) }) } }) vi.mock('@/components/ui/select-value.vue', async () => { const { defineComponent, h, inject } = await import('vue') return { default: defineComponent({ setup() { const select = inject('settings-test-select') return () => h('span', select?.value.value) }, }), } }) vi.mock('@/components/ui/select-content.vue', async () => { const { defineComponent, h } = await import('vue') return { default: defineComponent({ setup: (_, { slots }) => () => h('div', slots.default?.()) }) } }) vi.mock('@/components/ui/select-item.vue', async () => { const { defineComponent, h, inject } = await import('vue') return { default: defineComponent({ props: { value: { type: String, required: true } }, setup(props, { slots }) { const select = inject('settings-test-select') return () => h('button', { type: 'button', role: 'option', 'data-option-value': props.value, 'aria-selected': select?.value.value === props.value, onClick: () => select?.select(props.value), }, slots.default?.()) }, }), } }) const mountedApps: Array<{ app: App, root: HTMLElement }> = [] function serverPreferences() { return { theme: 'light', language: 'zh-CN', timezone: 'Asia/Shanghai', notifications: { email: true, usage_alerts: true, announcements: true }, } } function deferred() { let resolve!: (value: T) => void const promise = new Promise(complete => { resolve = complete }) return { promise, resolve } } async function flushPromises() { await nextTick() await new Promise(resolve => setTimeout(resolve, 0)) await nextTick() } function mountSettings() { const root = document.createElement('div') document.body.append(root) const app = createApp(Settings) app.mount(root) mountedApps.push({ app, root }) return root } function languageSelect(root: HTMLElement): HTMLElement { const select = root.querySelector('#language')?.closest('[data-select-value]') if (!select) throw new Error('The settings language select was not rendered') return select } function chooseEnglish(root: HTMLElement) { const option = languageSelect(root).querySelector('[data-option-value="en-US"]') if (!option) throw new Error('The English option was not rendered') expect(option.textContent?.trim()).toBe('English') option.click() } beforeEach(() => { vi.clearAllMocks() setI18nLocale('zh-CN') meApiMock.getProfile.mockResolvedValue({ id: 'settings-user', username: 'User', role: 'user', is_active: true, auth_source: 'ldap', feature_settings: {}, }) meApiMock.listSessions.mockResolvedValue([]) meApiMock.getPreferences.mockResolvedValue(serverPreferences()) meApiMock.updatePreferences.mockResolvedValue(undefined) }) afterEach(() => { for (const { app, root } of mountedApps.splice(0)) { app.unmount() root.remove() } }) describe('Settings language preferences', () => { it('initializes from the active locale and preserves it when old server preferences arrive', async () => { const preferences = deferred>() meApiMock.getPreferences.mockReturnValueOnce(preferences.promise) setI18nLocale('en-US') const root = mountSettings() expect(languageSelect(root).dataset.selectValue).toBe('en-US') preferences.resolve(serverPreferences()) await flushPromises() expect(languageSelect(root).dataset.selectValue).toBe('en-US') expect(languageSelect(root).querySelector('[data-option-value="en-US"]')?.getAttribute('aria-selected')).toBe('true') expect(getI18nLocale()).toBe('en-US') expect(localStorage.getItem('aether_locale')).toBe('en-US') }) it('switches locale and persists en-US immediately on the Select update event', async () => { const save = deferred() meApiMock.updatePreferences.mockReturnValueOnce(save.promise) const root = mountSettings() await flushPromises() expect(languageSelect(root).dataset.selectValue).toBe('zh-CN') chooseEnglish(root) expect(getI18nLocale()).toBe('en-US') expect(document.documentElement.lang).toBe('en-US') expect(localStorage.getItem('aether_locale')).toBe('en-US') expect(meApiMock.updatePreferences).toHaveBeenCalledWith(expect.objectContaining({ language: 'en-US' })) expect(toastMock.success).not.toHaveBeenCalled() await nextTick() expect(languageSelect(root).dataset.selectValue).toBe('en-US') save.resolve() await flushPromises() expect(toastMock.success).toHaveBeenCalled() expect(toastMock.error).not.toHaveBeenCalled() }) it('does not undo a new selection when the initial preference request completes late', async () => { const preferences = deferred>() meApiMock.getPreferences.mockReturnValueOnce(preferences.promise) const root = mountSettings() chooseEnglish(root) preferences.resolve(serverPreferences()) await flushPromises() expect(getI18nLocale()).toBe('en-US') expect(languageSelect(root).dataset.selectValue).toBe('en-US') expect(meApiMock.updatePreferences).toHaveBeenCalledTimes(1) expect(meApiMock.updatePreferences).toHaveBeenCalledWith(expect.objectContaining({ language: 'en-US' })) }) it('keeps its selected option synchronized with language changes from the top bar', async () => { const root = mountSettings() await flushPromises() setI18nLocale('en-US') await nextTick() expect(languageSelect(root).dataset.selectValue).toBe('en-US') expect(languageSelect(root).querySelector('[data-option-value="en-US"]')?.getAttribute('aria-selected')).toBe('true') setI18nLocale('zh-CN') await nextTick() expect(languageSelect(root).dataset.selectValue).toBe('zh-CN') expect(languageSelect(root).querySelector('[data-option-value="zh-CN"]')?.getAttribute('aria-selected')).toBe('true') expect(meApiMock.updatePreferences).not.toHaveBeenCalled() }) }) describe('Settings wallet fallback preferences', () => { function walletSwitch(root: HTMLElement): HTMLButtonElement { const control = root.querySelector('#allow-wallet-overage') if (!control) throw new Error('The wallet fallback switch was not rendered') return control } it('defaults to off for existing users and waits for preferences before allowing changes', async () => { const preferences = deferred>() meApiMock.getPreferences.mockReturnValueOnce(preferences.promise) const root = mountSettings() const control = walletSwitch(root) expect(control.getAttribute('aria-checked')).toBe('false') expect(control.disabled).toBe(true) control.click() expect(meApiMock.updatePreferences).not.toHaveBeenCalled() preferences.resolve(serverPreferences()) await flushPromises() expect(control.disabled).toBe(false) expect(control.getAttribute('aria-checked')).toBe('false') }) it('loads the saved choice and persists both disabling and enabling without changing other preferences', async () => { meApiMock.getPreferences.mockResolvedValue({ ...serverPreferences(), allow_wallet_overage: true }) const root = mountSettings() await flushPromises() const control = walletSwitch(root) expect(control.getAttribute('aria-checked')).toBe('true') control.click() await flushPromises() expect(meApiMock.updatePreferences).toHaveBeenLastCalledWith({ allow_wallet_overage: false }) expect(control.getAttribute('aria-checked')).toBe('false') control.click() await flushPromises() expect(meApiMock.updatePreferences).toHaveBeenLastCalledWith({ allow_wallet_overage: true }) expect(control.getAttribute('aria-checked')).toBe('true') chooseEnglish(root) await flushPromises() expect(meApiMock.updatePreferences.mock.lastCall?.[0]).not.toHaveProperty('allow_wallet_overage') }) it('prevents duplicate saves and restores the saved choice when saving fails', async () => { let reject!: (reason: Error) => void meApiMock.updatePreferences.mockReturnValueOnce(new Promise((_, fail) => { reject = fail })) const root = mountSettings() await flushPromises() const control = walletSwitch(root) control.click() await nextTick() expect(control.disabled).toBe(true) expect(control.getAttribute('aria-checked')).toBe('true') control.click() expect(meApiMock.updatePreferences).toHaveBeenCalledTimes(1) reject(new Error('Save failed')) await flushPromises() expect(control.disabled).toBe(false) expect(control.getAttribute('aria-checked')).toBe('false') expect(toastMock.error).toHaveBeenCalledWith('保存设置失败') expect(toastMock.success).not.toHaveBeenCalled() }) })