Files
Aether/frontend/src/views/user/__tests__/Settings.locale.spec.ts
T

299 lines
11 KiB
TypeScript

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<string | undefined>
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<SelectContext>('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<SelectContext>('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<SelectContext>('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<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>(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<HTMLElement>('[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<HTMLButtonElement>('[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<ReturnType<typeof serverPreferences>>()
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<void>()
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<ReturnType<typeof serverPreferences>>()
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<HTMLButtonElement>('#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<ReturnType<typeof serverPreferences>>()
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<void>((_, 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()
})
})