mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-09-02 14:07:15 +08:00
fix(setup): protect wizard step orchestration (#679)
This commit is contained in:
@@ -0,0 +1,993 @@
|
||||
import { useSetupWizard, type WizardData } from '@/composables/useSetupWizard'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
apiGet: vi.fn(),
|
||||
apiPost: vi.fn(),
|
||||
apiPut: vi.fn(),
|
||||
copyToClipboard: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
toastError: vi.fn(),
|
||||
toastSuccess: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
default: createDataApiMock({
|
||||
get: (...args: unknown[]) => mocks.apiGet(...args),
|
||||
post: (...args: unknown[]) => mocks.apiPost(...args),
|
||||
put: (...args: unknown[]) => mocks.apiPut(...args),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/@core/utils/navigator', () => ({
|
||||
copyToClipboard: (...args: unknown[]) => mocks.copyToClipboard(...args),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, params?: Record<string, unknown>) => (params ? `${key}:${Object.values(params).join(',')}` : key),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', () => ({
|
||||
useRouter: () => ({ push: mocks.routerPush }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-toastification', () => ({
|
||||
useToast: () => ({ error: mocks.toastError, success: mocks.toastSuccess }),
|
||||
}))
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason?: unknown) => void
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise
|
||||
reject = rejectPromise
|
||||
})
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
function cloneWizardData(data: WizardData) {
|
||||
return JSON.parse(JSON.stringify(data)) as WizardData
|
||||
}
|
||||
|
||||
const originalWizardData = cloneWizardData(useSetupWizard().wizardData.value)
|
||||
|
||||
function resetWizard() {
|
||||
const wizard = useSetupWizard()
|
||||
wizard.currentStep.value = 1
|
||||
wizard.wizardData.value = cloneWizardData(originalWizardData)
|
||||
wizard.authSites.value = {}
|
||||
wizard.selectedPreset.value = ''
|
||||
wizard.connectivityTest.value = {
|
||||
isTesting: false,
|
||||
showResult: false,
|
||||
testMessage: '',
|
||||
testProgress: 0,
|
||||
testResult: null,
|
||||
}
|
||||
wizard.clearValidationErrors()
|
||||
return wizard
|
||||
}
|
||||
|
||||
function success(data: unknown = null) {
|
||||
return { data, message: '', success: true }
|
||||
}
|
||||
|
||||
function businessFailure(message = '业务失败') {
|
||||
return { data: null, message, success: false }
|
||||
}
|
||||
|
||||
function currentUser() {
|
||||
return {
|
||||
avatar: 'avatar.png',
|
||||
email: 'admin@example.com',
|
||||
id: 7,
|
||||
is_active: true,
|
||||
is_otp: true,
|
||||
is_superuser: true,
|
||||
name: 'admin',
|
||||
nickname: '管理员',
|
||||
permissions: { manage: true },
|
||||
settings: { theme: 'dark' },
|
||||
}
|
||||
}
|
||||
|
||||
function seedValidStep(wizard: ReturnType<typeof useSetupWizard>, step: number) {
|
||||
wizard.currentStep.value = step
|
||||
wizard.wizardData.value.basic.username = 'admin'
|
||||
wizard.wizardData.value.basic.apiToken = 'token'
|
||||
wizard.wizardData.value.storage.downloadPath = '/downloads'
|
||||
wizard.wizardData.value.storage.libraryPath = '/media'
|
||||
|
||||
if (step === 4) {
|
||||
Object.assign(wizard.wizardData.value.downloader, {
|
||||
config: { host: 'http://qb', password: 'secret', username: 'admin' },
|
||||
name: '主下载器',
|
||||
type: 'qbittorrent',
|
||||
})
|
||||
}
|
||||
if (step === 5) {
|
||||
Object.assign(wizard.wizardData.value.mediaServer, {
|
||||
config: { apikey: 'key', host: 'http://emby' },
|
||||
name: '主媒体服务器',
|
||||
sync_libraries: ['Movies'],
|
||||
type: 'emby',
|
||||
})
|
||||
}
|
||||
if (step === 6) {
|
||||
Object.assign(wizard.wizardData.value.notification, {
|
||||
config: { TELEGRAM_CHAT_ID: '123', TELEGRAM_TOKEN: 'token' },
|
||||
enabled: true,
|
||||
name: 'Telegram 通知',
|
||||
switchs: ['资源下载'],
|
||||
type: 'telegram',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveConnectivity<T>(promise: Promise<T>) {
|
||||
await vi.advanceTimersByTimeAsync(2_000)
|
||||
return promise
|
||||
}
|
||||
|
||||
describe('useSetupWizard defaults, selection and validation', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.apiPut.mockReset()
|
||||
mocks.copyToClipboard.mockReset()
|
||||
mocks.routerPush.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.apiGet.mockResolvedValue(success())
|
||||
mocks.apiPost.mockResolvedValue(success())
|
||||
mocks.apiPut.mockResolvedValue(success())
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
resetWizard()
|
||||
})
|
||||
|
||||
it('exposes eight ordered steps and stable defaults', () => {
|
||||
const wizard = resetWizard()
|
||||
|
||||
expect(wizard.totalSteps).toBe(8)
|
||||
expect(wizard.stepTitles.value).toEqual([
|
||||
'setupWizard.basic.title',
|
||||
'setupWizard.siteAuth.title',
|
||||
'setupWizard.storage.title',
|
||||
'setupWizard.downloader.title',
|
||||
'setupWizard.mediaServer.title',
|
||||
'setupWizard.notification.title',
|
||||
'setupWizard.agent.title',
|
||||
'setupWizard.preferences.title',
|
||||
])
|
||||
expect(wizard.stepDescriptions.value).toHaveLength(8)
|
||||
expect(wizard.wizardData.value).toMatchObject({
|
||||
agent: {
|
||||
enabled: false,
|
||||
maxContextTokens: 64,
|
||||
model: 'deepseek-chat',
|
||||
provider: 'deepseek',
|
||||
thinkingLevel: 'off',
|
||||
},
|
||||
basic: { apiToken: '', recognizeSource: 'themoviedb', username: '' },
|
||||
preferences: { quality: '4K', resolution: '2160p', subtitle: 'chinese' },
|
||||
storage: { overwriteMode: 'never', transferType: 'link' },
|
||||
})
|
||||
})
|
||||
|
||||
it('selects, renames and toggles optional service types without clearing configuration', () => {
|
||||
const wizard = resetWizard()
|
||||
wizard.wizardData.value.downloader.config = { host: 'http://qb' }
|
||||
wizard.selectDownloader('qbittorrent')
|
||||
expect(wizard.wizardData.value.downloader).toMatchObject({
|
||||
config: { host: 'http://qb' },
|
||||
name: 'qbittorrent 下载器',
|
||||
type: 'qbittorrent',
|
||||
})
|
||||
wizard.selectDownloader('transmission')
|
||||
expect(wizard.wizardData.value.downloader.name).toBe('transmission 下载器')
|
||||
wizard.selectDownloader('transmission')
|
||||
expect(wizard.wizardData.value.downloader.type).toBe('')
|
||||
|
||||
wizard.wizardData.value.mediaServer.config = { token: 'token' }
|
||||
wizard.selectMediaServer('plex')
|
||||
expect(wizard.wizardData.value.mediaServer).toMatchObject({
|
||||
config: { token: 'token' },
|
||||
name: 'plex 服务器',
|
||||
type: 'plex',
|
||||
})
|
||||
wizard.selectMediaServer('plex')
|
||||
expect(wizard.wizardData.value.mediaServer.type).toBe('')
|
||||
|
||||
wizard.selectNotification('wechat')
|
||||
expect(wizard.wizardData.value.notification).toMatchObject({
|
||||
enabled: true,
|
||||
name: '企业微信 通知',
|
||||
type: 'wechat',
|
||||
})
|
||||
wizard.selectNotification('wechat')
|
||||
expect(wizard.wizardData.value.notification.type).toBe('')
|
||||
})
|
||||
|
||||
it('preserves custom service names and falls back to the raw notification type', () => {
|
||||
const wizard = resetWizard()
|
||||
wizard.wizardData.value.downloader.name = '下载专线'
|
||||
wizard.selectDownloader('qbittorrent')
|
||||
expect(wizard.wizardData.value.downloader.name).toBe('下载专线')
|
||||
|
||||
wizard.wizardData.value.mediaServer.name = '客厅媒体库'
|
||||
wizard.selectMediaServer('plex')
|
||||
expect(wizard.wizardData.value.mediaServer.name).toBe('客厅媒体库')
|
||||
|
||||
wizard.wizardData.value.notification.name = ''
|
||||
wizard.selectNotification('custom')
|
||||
expect(wizard.wizardData.value.notification.name).toBe('custom 通知')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['4k', '4K', 'bilingual', '2160p'],
|
||||
['balanced', '1080P', 'chinese', '1080p'],
|
||||
['chinese', '1080P', 'chinese', '1080p'],
|
||||
])('applies the %s preference preset', (preset, quality, subtitle, resolution) => {
|
||||
const wizard = resetWizard()
|
||||
wizard.selectPreset(preset)
|
||||
|
||||
expect(wizard.selectedPreset.value).toBe(preset)
|
||||
expect(wizard.wizardData.value.preferences).toMatchObject({ quality, resolution, subtitle })
|
||||
})
|
||||
|
||||
it('stores detailed preference rules', () => {
|
||||
const wizard = resetWizard()
|
||||
const options = { excludeBluray: true, excludeDolbyVision: false }
|
||||
const rules = [{ category: '电影', media_type: '电影', name: '高画质', rule_string: '4K' }]
|
||||
|
||||
wizard.updatePreferences(options, rules)
|
||||
|
||||
expect(wizard.wizardData.value.preferences).toMatchObject({
|
||||
personalizationOptions: options,
|
||||
ruleSequences: rules,
|
||||
})
|
||||
})
|
||||
|
||||
it('generates and copies API tokens with success and failure feedback', async () => {
|
||||
const wizard = resetWizard()
|
||||
vi.spyOn(window.crypto, 'getRandomValues').mockImplementation(array => {
|
||||
;(array as Uint8Array).fill(1)
|
||||
return array
|
||||
})
|
||||
wizard.createRandomString()
|
||||
expect(wizard.wizardData.value.basic.apiToken).toBe('1'.repeat(32))
|
||||
|
||||
mocks.copyToClipboard
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockRejectedValueOnce(new Error('no'))
|
||||
await wizard.copyValue('token')
|
||||
await wizard.copyValue('token')
|
||||
await wizard.copyValue('token')
|
||||
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('setting.system.copySuccess')
|
||||
expect(mocks.toastError.mock.calls).toEqual([['setting.system.copyFailed'], ['setting.system.copyError']])
|
||||
})
|
||||
|
||||
it('validates basic, storage and missing site-auth values', () => {
|
||||
const wizard = resetWizard()
|
||||
expect(wizard.validateCurrentStep()).toEqual({
|
||||
errors: ['dialog.userAddEdit.usernameRequired', 'setupWizard.basic.apiTokenRequired'],
|
||||
isValid: false,
|
||||
})
|
||||
|
||||
Object.assign(wizard.wizardData.value.basic, {
|
||||
apiToken: 'token',
|
||||
confirmPassword: 'different',
|
||||
password: 'short',
|
||||
username: 'admin',
|
||||
})
|
||||
expect(wizard.validateCurrentStep().errors).toEqual([
|
||||
'dialog.userAddEdit.passwordMinLength',
|
||||
'dialog.userAddEdit.passwordMismatch',
|
||||
])
|
||||
|
||||
wizard.currentStep.value = 2
|
||||
wizard.wizardData.value.siteAuth.site = 'm-team'
|
||||
expect(wizard.validateCurrentStep().errors).toEqual(['setupWizard.siteAuth.siteConfigNotExist'])
|
||||
|
||||
wizard.authSites.value = {
|
||||
'm-team': {
|
||||
icon: 'm-team.png',
|
||||
name: 'M-Team',
|
||||
params: { api_key: { name: 'API Key', type: 'text' } },
|
||||
},
|
||||
}
|
||||
expect(wizard.validateCurrentStep().errors).toEqual(['setupWizard.siteAuth.fieldRequired:API Key'])
|
||||
wizard.wizardData.value.siteAuth.params['M-TEAM_API_KEY'] = 'key'
|
||||
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
|
||||
|
||||
wizard.currentStep.value = 3
|
||||
expect(wizard.validateCurrentStep().errors).toEqual([
|
||||
'setupWizard.storage.downloadPathRequired',
|
||||
'setupWizard.storage.libraryPathRequired',
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts an omitted optional site and reports a missing password confirmation', () => {
|
||||
const wizard = resetWizard()
|
||||
wizard.currentStep.value = 2
|
||||
expect(wizard.validateSiteAuthFields()).toEqual({ errors: [], isValid: true })
|
||||
|
||||
wizard.currentStep.value = 1
|
||||
Object.assign(wizard.wizardData.value.basic, {
|
||||
apiToken: 'token',
|
||||
confirmPassword: '',
|
||||
password: 'long-enough',
|
||||
username: 'admin',
|
||||
})
|
||||
expect(wizard.validateCurrentStep().errors).toEqual(['dialog.userAddEdit.confirmPasswordRequired'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['qbittorrent', { host: 'http://qb' }, ['downloader.usernameRequired', 'downloader.passwordRequired']],
|
||||
['transmission', { host: 'http://tr' }, ['downloader.usernameRequired', 'downloader.passwordRequired']],
|
||||
['rtorrent', { host: 'http://rt' }, ['downloader.usernameRequired', 'downloader.passwordRequired']],
|
||||
])('validates the %s downloader contract', (type, config, expectedErrors) => {
|
||||
const wizard = resetWizard()
|
||||
wizard.currentStep.value = 4
|
||||
Object.assign(wizard.wizardData.value.downloader, { config, name: '下载器', type })
|
||||
expect(wizard.validateCurrentStep().errors).toEqual(expectedErrors)
|
||||
|
||||
wizard.wizardData.value.downloader.config = {
|
||||
...config,
|
||||
apikey: type === 'qbittorrent' ? 'key' : undefined,
|
||||
password: 'password',
|
||||
username: 'admin',
|
||||
}
|
||||
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['emby', { host: 'http://emby' }, 'mediaserver.apiKeyRequired'],
|
||||
['jellyfin', { host: 'http://jellyfin' }, 'mediaserver.apiKeyRequired'],
|
||||
['plex', { host: 'http://plex' }, 'mediaserver.tokenRequired'],
|
||||
['zspace', { host: 'http://zspace' }, 'mediaserver.usernameRequired'],
|
||||
['trimemedia', { host: 'http://trimemedia' }, 'mediaserver.usernameRequired'],
|
||||
['ugreen', { host: 'http://ugreen' }, 'mediaserver.usernameRequired'],
|
||||
])('validates the %s media-server contract', (type, config, expectedError) => {
|
||||
const wizard = resetWizard()
|
||||
wizard.currentStep.value = 5
|
||||
Object.assign(wizard.wizardData.value.mediaServer, { config, name: '媒体服务器', type })
|
||||
expect(wizard.validateCurrentStep().errors).toContain(expectedError)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['emby', { apikey: 'key', host: 'http://emby' }],
|
||||
['jellyfin', { apikey: 'key', host: 'http://jellyfin' }],
|
||||
['plex', { host: 'http://plex', token: 'token' }],
|
||||
['zspace', { host: 'http://zspace', password: 'secret', username: 'admin' }],
|
||||
['trimemedia', { host: 'http://trimemedia', password: 'secret', username: 'admin' }],
|
||||
['ugreen', { host: 'http://ugreen', password: 'secret', username: 'admin' }],
|
||||
])('accepts the complete %s media-server contract', (type, config) => {
|
||||
const wizard = resetWizard()
|
||||
wizard.currentStep.value = 5
|
||||
Object.assign(wizard.wizardData.value.mediaServer, { config, name: '媒体服务器', type })
|
||||
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['wechat', {}, 'notification.wechat.corpIdRequired'],
|
||||
['feishu', {}, 'notification.feishu.appIdRequired'],
|
||||
['telegram', {}, 'notification.telegram.tokenRequired'],
|
||||
['slack', {}, 'notification.slack.oauthTokenRequired'],
|
||||
['synologychat', {}, 'notification.synologychat.webhookRequired'],
|
||||
['vocechat', {}, 'notification.vocechat.hostRequired'],
|
||||
['webpush', {}, 'notification.webpush.usernameRequired'],
|
||||
['qqbot', {}, 'notification.qqbot.appIdRequired'],
|
||||
['wechatclawbot', {}, null],
|
||||
])('validates the %s notification contract', (type, config, expectedError) => {
|
||||
const wizard = resetWizard()
|
||||
wizard.currentStep.value = 6
|
||||
Object.assign(wizard.wizardData.value.notification, { config, name: '通知', type })
|
||||
const result = wizard.validateCurrentStep()
|
||||
if (expectedError) expect(result.errors).toContain(expectedError)
|
||||
else expect(result).toEqual({ errors: [], isValid: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['wechat', { WECHAT_APP_ID: 'app', WECHAT_APP_SECRET: 'secret', WECHAT_CORPID: 'corp' }],
|
||||
['feishu', { FEISHU_APP_ID: 'app', FEISHU_APP_SECRET: 'secret' }],
|
||||
['telegram', { TELEGRAM_CHAT_ID: 'chat', TELEGRAM_TOKEN: 'token' }],
|
||||
['slack', { SLACK_CHANNEL: 'channel', SLACK_OAUTH_TOKEN: 'token' }],
|
||||
['synologychat', { SYNOLOGYCHAT_WEBHOOK: 'https://chat.example.com' }],
|
||||
['vocechat', { VOCECHAT_API_KEY: 'key', VOCECHAT_HOST: 'https://voce.example.com' }],
|
||||
['webpush', { WEBPUSH_USERNAME: 'admin' }],
|
||||
['qqbot', { QQ_APP_ID: 'app', QQ_APP_SECRET: 'secret' }],
|
||||
])('accepts the complete %s notification contract', (type, config) => {
|
||||
const wizard = resetWizard()
|
||||
wizard.currentStep.value = 6
|
||||
Object.assign(wizard.wizardData.value.notification, { config, name: '通知', type })
|
||||
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
|
||||
})
|
||||
|
||||
it('reports missing service names and hosts before provider-specific fields', () => {
|
||||
const wizard = resetWizard()
|
||||
wizard.currentStep.value = 4
|
||||
Object.assign(wizard.wizardData.value.downloader, { config: {}, name: '', type: 'qbittorrent' })
|
||||
expect(wizard.validateCurrentStep().errors).toEqual([
|
||||
'downloader.nameRequired',
|
||||
'downloader.hostRequired',
|
||||
'downloader.usernameRequired',
|
||||
'downloader.passwordRequired',
|
||||
])
|
||||
|
||||
wizard.currentStep.value = 5
|
||||
Object.assign(wizard.wizardData.value.mediaServer, { config: {}, name: '', type: 'plex' })
|
||||
expect(wizard.validateCurrentStep().errors).toEqual([
|
||||
'mediaserver.nameRequired',
|
||||
'mediaserver.hostRequired',
|
||||
'mediaserver.tokenRequired',
|
||||
])
|
||||
|
||||
wizard.currentStep.value = 6
|
||||
Object.assign(wizard.wizardData.value.notification, { config: undefined, name: '', type: 'wechatclawbot' })
|
||||
expect(wizard.validateCurrentStep().errors).toEqual(['notification.nameRequired'])
|
||||
})
|
||||
|
||||
it('validates enabled agent fields and skips them when disabled', () => {
|
||||
const wizard = resetWizard()
|
||||
wizard.currentStep.value = 7
|
||||
wizard.wizardData.value.agent.enabled = true
|
||||
Object.assign(wizard.wizardData.value.agent, {
|
||||
apiKey: '',
|
||||
authConnected: false,
|
||||
maxContextTokens: 0,
|
||||
model: '',
|
||||
provider: '',
|
||||
recommendEnabled: true,
|
||||
recommendMaxItems: 0,
|
||||
})
|
||||
expect(wizard.validateCurrentStep().errors).toEqual([
|
||||
'setupWizard.agent.providerRequired',
|
||||
'setupWizard.agent.authOrApiKeyRequired',
|
||||
'setupWizard.agent.modelRequired',
|
||||
'setupWizard.agent.maxContextTokensRequired',
|
||||
'setupWizard.agent.recommendMaxItemsRequired',
|
||||
])
|
||||
|
||||
wizard.wizardData.value.agent.enabled = false
|
||||
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
|
||||
wizard.currentStep.value = 8
|
||||
expect(wizard.validateCurrentStep()).toEqual({ errors: [], isValid: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSetupWizard initialization and step payloads', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.apiPut.mockReset()
|
||||
mocks.routerPush.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.apiPost.mockResolvedValue(success())
|
||||
mocks.apiPut.mockResolvedValue(success())
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
resetWizard()
|
||||
})
|
||||
|
||||
it('loads existing settings across all eight setup domains', async () => {
|
||||
mocks.apiGet.mockImplementation((endpoint: unknown) => {
|
||||
const responses: Record<string, unknown> = {
|
||||
'site/auth': success({
|
||||
'm-team': { icon: 'm-team.png', name: 'M-Team', params: {} },
|
||||
}),
|
||||
'system/env': success({
|
||||
AI_AGENT_ENABLE: true,
|
||||
AI_AGENT_GLOBAL: true,
|
||||
AI_AGENT_JOB_INTERVAL: 15,
|
||||
AI_AGENT_RECOMMEND_MAX_ITEMS: 25,
|
||||
AI_AGENT_RETRY_TRANSFER: true,
|
||||
AI_RECOMMEND_ENABLED: true,
|
||||
API_TOKEN: 'existing-token',
|
||||
APP_DOMAIN: 'https://mp.example.com',
|
||||
AUXILIARY_AUTH_ENABLE: true,
|
||||
GITHUB_TOKEN: 'github-token',
|
||||
LLM_API_KEY: 'llm-key',
|
||||
LLM_DISABLE_THINKING: false,
|
||||
LLM_MAX_CONTEXT_TOKENS: 128,
|
||||
LLM_MODEL: 'deepseek-reasoner',
|
||||
LLM_PROVIDER: 'deepseek',
|
||||
LLM_REASONING_EFFORT: 'high',
|
||||
OCR_HOST: 'http://ocr',
|
||||
PROXY_HOST: 'http://proxy',
|
||||
SUPERUSER: 'admin',
|
||||
}),
|
||||
'system/setting/Downloaders': success({
|
||||
value: [{ config: { host: 'http://qb' }, name: 'QB', type: 'qbittorrent' }],
|
||||
}),
|
||||
'system/setting/MediaServers': success({
|
||||
value: [{ config: { host: 'http://emby' }, name: 'Emby', sync_libraries: ['Movies'], type: 'emby' }],
|
||||
}),
|
||||
'system/setting/Notifications': success({
|
||||
value: [{ config: { TOKEN: 'token' }, enabled: true, name: 'Bot', switchs: ['下载'], type: 'telegram' }],
|
||||
}),
|
||||
'system/setting/UserSiteAuthParams': success({ value: { params: { M_TEAM_KEY: 'key' }, site: 'm-team' } }),
|
||||
'system/setting/public/Directories': success({
|
||||
value: [
|
||||
{
|
||||
download_path: '/downloads',
|
||||
library_path: '/media',
|
||||
overwrite_mode: 'always',
|
||||
transfer_type: 'copy',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
return responses[String(endpoint)] ?? success()
|
||||
})
|
||||
|
||||
const wizard = resetWizard()
|
||||
const initializing = wizard.initialize()
|
||||
expect(wizard.isLoading.value).toBe(true)
|
||||
await initializing
|
||||
|
||||
expect(wizard.isLoading.value).toBe(false)
|
||||
expect(wizard.wizardData.value).toMatchObject({
|
||||
agent: {
|
||||
apiKey: 'llm-key',
|
||||
enabled: true,
|
||||
global: true,
|
||||
maxContextTokens: 128,
|
||||
model: 'deepseek-reasoner',
|
||||
thinkingLevel: 'high',
|
||||
},
|
||||
basic: {
|
||||
apiToken: 'existing-token',
|
||||
appDomain: 'https://mp.example.com',
|
||||
githubToken: 'github-token',
|
||||
username: 'admin',
|
||||
},
|
||||
downloader: { name: 'QB', type: 'qbittorrent' },
|
||||
mediaServer: { name: 'Emby', sync_libraries: ['Movies'], type: 'emby' },
|
||||
notification: { enabled: true, name: 'Bot', type: 'telegram' },
|
||||
siteAuth: { auxiliaryAuthEnable: true, site: 'm-team' },
|
||||
storage: {
|
||||
downloadPath: '/downloads',
|
||||
libraryPath: '/media',
|
||||
overwriteMode: 'always',
|
||||
transferType: 'copy',
|
||||
},
|
||||
})
|
||||
expect(wizard.authSites.value).toHaveProperty('m-team')
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ LLM_THINKING_LEVEL: 'ENABLED' }, 'auto'],
|
||||
[{ LLM_DISABLE_THINKING: true, LLM_REASONING_EFFORT: 'high' }, 'off'],
|
||||
[{ LLM_DISABLE_THINKING: false }, 'auto'],
|
||||
])('normalizes thinking configuration %#', async (environment, expected) => {
|
||||
mocks.apiGet.mockImplementation((endpoint: unknown) =>
|
||||
endpoint === 'system/env' ? success(environment) : success(),
|
||||
)
|
||||
const wizard = resetWizard()
|
||||
await wizard.initialize()
|
||||
expect(wizard.wizardData.value.agent.thinkingLevel).toBe(expected)
|
||||
})
|
||||
|
||||
it('applies defaults for sparse successful responses and generates a missing API token', async () => {
|
||||
mocks.apiGet.mockImplementation((endpoint: unknown) => {
|
||||
const responses: Record<string, unknown> = {
|
||||
'site/auth': success(null),
|
||||
'system/env': success({ LLM_TEMPERATURE: 'invalid' }),
|
||||
'system/setting/Downloaders': success({ value: [{ config: null, name: 'QB', type: 'qbittorrent' }] }),
|
||||
'system/setting/MediaServers': success({
|
||||
value: [{ config: null, name: 'Emby', sync_libraries: null, type: 'emby' }],
|
||||
}),
|
||||
'system/setting/Notifications': success({
|
||||
value: [{ config: null, enabled: false, name: 'Bot', switchs: null, type: 'telegram' }],
|
||||
}),
|
||||
'system/setting/UserSiteAuthParams': success({ value: {} }),
|
||||
'system/setting/public/Directories': success({ value: [{}] }),
|
||||
}
|
||||
return responses[String(endpoint)] ?? success()
|
||||
})
|
||||
const wizard = resetWizard()
|
||||
|
||||
await wizard.initialize()
|
||||
|
||||
expect(wizard.wizardData.value.basic.apiToken).toHaveLength(32)
|
||||
expect(wizard.wizardData.value.agent).toMatchObject({
|
||||
maxContextTokens: 64,
|
||||
model: '',
|
||||
provider: 'deepseek',
|
||||
temperature: 0.3,
|
||||
})
|
||||
expect(wizard.wizardData.value.siteAuth).toMatchObject({ params: {}, site: '' })
|
||||
expect(wizard.wizardData.value.storage).toMatchObject({
|
||||
downloadPath: '',
|
||||
libraryPath: '',
|
||||
overwriteMode: 'never',
|
||||
transferType: 'link',
|
||||
})
|
||||
expect(wizard.wizardData.value.downloader.config).toEqual({})
|
||||
expect(wizard.wizardData.value.mediaServer).toMatchObject({ config: {}, sync_libraries: [] })
|
||||
expect(wizard.wizardData.value.notification).toMatchObject({ config: {}, switchs: [] })
|
||||
expect(wizard.authSites.value).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps defaults and finishes initialization when every load endpoint fails', async () => {
|
||||
mocks.apiGet.mockRejectedValue(new Error('offline'))
|
||||
const wizard = resetWizard()
|
||||
|
||||
await expect(wizard.initialize()).resolves.toBeUndefined()
|
||||
|
||||
expect(wizard.isLoading.value).toBe(false)
|
||||
expect(wizard.wizardData.value.storage).toMatchObject({
|
||||
downloadPath: '',
|
||||
libraryPath: '',
|
||||
overwriteMode: 'never',
|
||||
transferType: 'link',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves the basic environment payload without changing the password when it is blank', async () => {
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 1)
|
||||
Object.assign(wizard.wizardData.value.basic, {
|
||||
appDomain: 'https://mp.example.com',
|
||||
githubToken: 'github-token',
|
||||
ocrHost: 'http://ocr',
|
||||
proxyHost: 'http://proxy',
|
||||
})
|
||||
|
||||
await expect(wizard.nextStep()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/env', {
|
||||
API_TOKEN: 'token',
|
||||
APP_DOMAIN: 'https://mp.example.com',
|
||||
GITHUB_TOKEN: 'github-token',
|
||||
OCR_HOST: 'http://ocr',
|
||||
PROXY_HOST: 'http://proxy',
|
||||
RECOGNIZE_SOURCE: 'themoviedb',
|
||||
})
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
expect(wizard.currentStep.value).toBe(2)
|
||||
})
|
||||
|
||||
it('updates the current user password through the complete User contract', async () => {
|
||||
mocks.apiGet.mockResolvedValue(success(currentUser()))
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 1)
|
||||
const passwordValue = ['updated', currentUser().id, 'credential'].join('-')
|
||||
Object.assign(wizard.wizardData.value.basic, {
|
||||
confirmPassword: passwordValue,
|
||||
password: passwordValue,
|
||||
})
|
||||
|
||||
await expect(wizard.nextStep()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('user/current')
|
||||
expect(mocks.apiPut).toHaveBeenCalledWith('user/', {
|
||||
avatar: 'avatar.png',
|
||||
email: 'admin@example.com',
|
||||
id: 7,
|
||||
is_active: true,
|
||||
is_otp: true,
|
||||
is_superuser: true,
|
||||
name: 'admin',
|
||||
password: passwordValue,
|
||||
permissions: { manage: true },
|
||||
settings: { theme: 'dark' },
|
||||
})
|
||||
expect(mocks.apiPost).not.toHaveBeenCalledWith('user/', expect.anything())
|
||||
})
|
||||
|
||||
it('saves site authentication and storage payloads in their owning steps', async () => {
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 2)
|
||||
Object.assign(wizard.wizardData.value.siteAuth, {
|
||||
auxiliaryAuthEnable: true,
|
||||
params: { 'M-TEAM_API_KEY': 'key' },
|
||||
site: 'm-team',
|
||||
})
|
||||
wizard.authSites.value = {
|
||||
'm-team': { icon: '', name: 'M-Team', params: { api_key: { name: 'API Key', type: 'text' } } },
|
||||
}
|
||||
|
||||
await expect(wizard.nextStep()).resolves.toBe(true)
|
||||
expect(mocks.apiPost.mock.calls).toEqual([
|
||||
['system/env', { AUXILIARY_AUTH_ENABLE: true }],
|
||||
['site/auth', { params: { 'M-TEAM_API_KEY': 'key' }, site: 'm-team' }],
|
||||
])
|
||||
|
||||
mocks.apiPost.mockClear()
|
||||
seedValidStep(wizard, 3)
|
||||
vi.useFakeTimers()
|
||||
await resolveConnectivity(wizard.nextStep())
|
||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(1, 'system/setting/Storages', [
|
||||
{ config: {}, name: '本地存储', type: 'local' },
|
||||
])
|
||||
expect(mocks.apiPost).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'system/setting/Directories',
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
download_path: '/downloads',
|
||||
library_path: '/media',
|
||||
overwrite_mode: 'never',
|
||||
transfer_type: 'link',
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith('system/moduletest/FileManagerModule')
|
||||
})
|
||||
|
||||
it('saves auxiliary authentication without a site request when no site is selected', async () => {
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 2)
|
||||
wizard.wizardData.value.siteAuth.auxiliaryAuthEnable = true
|
||||
|
||||
await expect(wizard.nextStep()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.apiPost.mock.calls).toEqual([['system/env', { AUXILIARY_AUTH_ENABLE: true }]])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[4, 'system/setting/Downloaders', 'system/moduletest/QbittorrentModule'],
|
||||
[5, 'system/setting/MediaServers', 'system/moduletest/EmbyModule'],
|
||||
[6, 'system/setting/Notifications', 'system/moduletest/TelegramModule'],
|
||||
])('saves and tests step %i before advancing', async (step, saveEndpoint, testEndpoint) => {
|
||||
vi.useFakeTimers()
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, step)
|
||||
|
||||
await expect(resolveConnectivity(wizard.nextStep())).resolves.toBe(true)
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(saveEndpoint, expect.any(Array))
|
||||
expect(mocks.apiGet).toHaveBeenCalledWith(testEndpoint)
|
||||
expect(wizard.currentStep.value).toBe(step + 1)
|
||||
})
|
||||
|
||||
it('skips optional service writes and tests when no service is selected', async () => {
|
||||
const wizard = resetWizard()
|
||||
|
||||
for (const step of [4, 5, 6]) {
|
||||
wizard.currentStep.value = step
|
||||
await expect(wizard.nextStep()).resolves.toBe(true)
|
||||
}
|
||||
|
||||
expect(mocks.apiPost).not.toHaveBeenCalled()
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
expect(wizard.currentStep.value).toBe(7)
|
||||
})
|
||||
|
||||
it('saves the full agent environment mapping', async () => {
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 7)
|
||||
Object.assign(wizard.wizardData.value.agent, {
|
||||
apiKey: 'llm-key',
|
||||
enabled: true,
|
||||
global: true,
|
||||
model: 'deepseek-chat',
|
||||
provider: 'deepseek',
|
||||
recommendEnabled: true,
|
||||
retryTransfer: true,
|
||||
})
|
||||
|
||||
await expect(wizard.nextStep()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||
'system/env',
|
||||
expect.objectContaining({
|
||||
AI_AGENT_ENABLE: true,
|
||||
AI_AGENT_GLOBAL: true,
|
||||
AI_AGENT_RETRY_TRANSFER: true,
|
||||
AI_RECOMMEND_ENABLED: true,
|
||||
LLM_API_KEY: 'llm-key',
|
||||
LLM_MODEL: 'deepseek-chat',
|
||||
LLM_PROVIDER: 'deepseek',
|
||||
}),
|
||||
)
|
||||
expect(wizard.currentStep.value).toBe(8)
|
||||
})
|
||||
|
||||
it('normalizes disabled agent payload values and invalid temperature', async () => {
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 7)
|
||||
Object.assign(wizard.wizardData.value.agent, {
|
||||
apiProtocol: '',
|
||||
audioInputProvider: '',
|
||||
audioOutputProvider: '',
|
||||
baseUrl: '',
|
||||
enabled: false,
|
||||
global: true,
|
||||
jobInterval: 15,
|
||||
retryTransfer: true,
|
||||
temperature: Number.NaN,
|
||||
verbose: true,
|
||||
webSearchMode: '',
|
||||
})
|
||||
|
||||
await expect(wizard.nextStep()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith(
|
||||
'system/env',
|
||||
expect.objectContaining({
|
||||
AI_AGENT_GLOBAL: false,
|
||||
AI_AGENT_JOB_INTERVAL: 0,
|
||||
AI_AGENT_RETRY_TRANSFER: false,
|
||||
AI_AGENT_VERBOSE: false,
|
||||
AUDIO_INPUT_PROVIDER: 'openai',
|
||||
AUDIO_OUTPUT_PROVIDER: 'openai',
|
||||
LLM_API_PROTOCOL: 'auto',
|
||||
LLM_BASE_URL: null,
|
||||
LLM_TEMPERATURE: 0.3,
|
||||
LLM_WEB_SEARCH_MODE: 'local',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('saves all preference rule groups before reporting the final step as saved', async () => {
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 8)
|
||||
wizard.wizardData.value.preferences.ruleSequences = [
|
||||
{ category: '电影', media_type: '电影', name: '高画质', rule_string: '4K' },
|
||||
{ category: '电视剧', media_type: '电视剧', name: '小体积', rule_string: '1080P' },
|
||||
]
|
||||
|
||||
await expect(wizard.nextStep()).resolves.toBe(true)
|
||||
|
||||
expect(mocks.apiPost.mock.calls).toEqual([
|
||||
['system/setting/UserFilterRuleGroups', wizard.wizardData.value.preferences.ruleSequences],
|
||||
['system/setting/SubscribeFilterRuleGroups', ['高画质', '小体积']],
|
||||
['system/setting/BestVersionFilterRuleGroups', ['高画质', '小体积']],
|
||||
])
|
||||
expect(wizard.currentStep.value).toBe(8)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useSetupWizard failure and concurrency boundaries', () => {
|
||||
beforeEach(() => {
|
||||
mocks.apiGet.mockReset()
|
||||
mocks.apiPost.mockReset()
|
||||
mocks.apiPut.mockReset()
|
||||
mocks.routerPush.mockReset()
|
||||
mocks.toastError.mockReset()
|
||||
mocks.toastSuccess.mockReset()
|
||||
mocks.apiGet.mockResolvedValue(success())
|
||||
mocks.apiPost.mockResolvedValue(success())
|
||||
mocks.apiPut.mockResolvedValue(success())
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
resetWizard()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', businessFailure('保存被拒绝')],
|
||||
['HTTP failure', new Error('offline')],
|
||||
])('does not advance when a step save has a %s', async (_label, failure) => {
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 1)
|
||||
if (failure instanceof Error) mocks.apiPost.mockRejectedValueOnce(failure)
|
||||
else mocks.apiPost.mockResolvedValueOnce(failure)
|
||||
|
||||
await expect(wizard.nextStep()).resolves.toBe(false)
|
||||
|
||||
expect(wizard.currentStep.value).toBe(1)
|
||||
expect(mocks.toastError).toHaveBeenCalledWith('setupWizard.saveBasicSettingsFailed')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['business failure', businessFailure('测试被拒绝')],
|
||||
['HTTP failure', new Error('offline')],
|
||||
])('does not advance when connectivity has a %s', async (_label, failure) => {
|
||||
vi.useFakeTimers()
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 4)
|
||||
if (failure instanceof Error) mocks.apiGet.mockRejectedValueOnce(failure)
|
||||
else mocks.apiGet.mockResolvedValueOnce(failure)
|
||||
|
||||
await expect(resolveConnectivity(wizard.nextStep())).resolves.toBe(false)
|
||||
|
||||
expect(wizard.currentStep.value).toBe(4)
|
||||
expect(wizard.connectivityTest.value).toMatchObject({
|
||||
isTesting: false,
|
||||
showResult: true,
|
||||
testResult: 'error',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects unsupported connectivity types without calling a module endpoint', async () => {
|
||||
vi.useFakeTimers()
|
||||
const wizard = resetWizard()
|
||||
wizard.wizardData.value.downloader.type = 'unknown'
|
||||
await expect(resolveConnectivity(wizard.testConnectivity(4))).resolves.toBe(false)
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
expect(wizard.connectivityTest.value.testMessage).toBe('setupWizard.unsupportedDownloaderType:unknown')
|
||||
|
||||
wizard.wizardData.value.mediaServer.type = 'unknown'
|
||||
await expect(resolveConnectivity(wizard.testConnectivity(5))).resolves.toBe(false)
|
||||
wizard.wizardData.value.notification.type = 'unknown'
|
||||
await expect(resolveConnectivity(wizard.testConnectivity(6))).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('allows only one next action while the current save is pending', async () => {
|
||||
const pendingSave = deferred<ReturnType<typeof success>>()
|
||||
mocks.apiPost.mockReturnValue(pendingSave.promise)
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 1)
|
||||
|
||||
const first = wizard.nextStep()
|
||||
const second = wizard.nextStep()
|
||||
expect(mocks.apiPost).toHaveBeenCalledTimes(1)
|
||||
|
||||
pendingSave.resolve(success())
|
||||
await expect(Promise.all([first, second])).resolves.toEqual([true, false])
|
||||
expect(wizard.currentStep.value).toBe(2)
|
||||
})
|
||||
|
||||
it('does not move backward while a next action is pending', async () => {
|
||||
const pendingSave = deferred<ReturnType<typeof success>>()
|
||||
mocks.apiPost.mockReturnValue(pendingSave.promise)
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 2)
|
||||
|
||||
const next = wizard.nextStep()
|
||||
wizard.prevStep()
|
||||
expect(wizard.currentStep.value).toBe(2)
|
||||
|
||||
pendingSave.resolve(success())
|
||||
await expect(next).resolves.toBe(true)
|
||||
expect(wizard.currentStep.value).toBe(3)
|
||||
})
|
||||
|
||||
it('uses the starting-step snapshot when a save result arrives late', async () => {
|
||||
const pendingSave = deferred<ReturnType<typeof success>>()
|
||||
mocks.apiPost.mockReturnValue(pendingSave.promise)
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 2)
|
||||
|
||||
const next = wizard.nextStep()
|
||||
wizard.currentStep.value = 6
|
||||
pendingSave.resolve(success())
|
||||
await expect(next).resolves.toBe(false)
|
||||
|
||||
expect(wizard.currentStep.value).toBe(6)
|
||||
expect(mocks.apiGet).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['system/setting/UserFilterRuleGroups'],
|
||||
['system/setting/SubscribeFilterRuleGroups'],
|
||||
['system/setting/BestVersionFilterRuleGroups'],
|
||||
])('does not complete when %s rejects its rule payload', async failingEndpoint => {
|
||||
mocks.apiPost.mockImplementation((endpoint: unknown) =>
|
||||
endpoint === failingEndpoint ? businessFailure('规则保存失败') : success(),
|
||||
)
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 8)
|
||||
wizard.wizardData.value.preferences.ruleSequences = [
|
||||
{ category: '电影', media_type: '电影', name: '高画质', rule_string: '4K' },
|
||||
]
|
||||
|
||||
await wizard.completeWizard()
|
||||
|
||||
expect(mocks.apiPost).not.toHaveBeenCalledWith('system/setting/SetupWizardState', '1')
|
||||
expect(mocks.routerPush).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats SetupWizardState as best-effort and still navigates after all required writes succeed', async () => {
|
||||
mocks.apiPost.mockImplementation((endpoint: unknown) =>
|
||||
endpoint === 'system/setting/SetupWizardState' ? businessFailure('状态保存失败') : success(),
|
||||
)
|
||||
const wizard = resetWizard()
|
||||
seedValidStep(wizard, 8)
|
||||
|
||||
await wizard.completeWizard()
|
||||
|
||||
expect(mocks.apiPost).toHaveBeenCalledWith('system/setting/SetupWizardState', '1')
|
||||
expect(mocks.toastSuccess).toHaveBeenCalledWith('setupWizard.completed')
|
||||
expect(mocks.routerPush).toHaveBeenCalledWith('/')
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import api from '@/api'
|
||||
import { copyToClipboard } from '@/@core/utils/navigator'
|
||||
import type { User } from '@/api/types'
|
||||
import type { DownloaderConf, MediaServerConf, NotificationConf, TransferDirectoryConf, User } from '@/api/types'
|
||||
|
||||
export interface WizardData {
|
||||
basic: {
|
||||
@@ -32,21 +32,21 @@ export interface WizardData {
|
||||
downloader: {
|
||||
type: string
|
||||
name: string
|
||||
config: any
|
||||
config: DownloaderConf['config']
|
||||
}
|
||||
mediaServer: {
|
||||
type: string
|
||||
name: string
|
||||
config: any
|
||||
sync_libraries: any[]
|
||||
switchs: any[]
|
||||
config: MediaServerConf['config']
|
||||
sync_libraries: NonNullable<MediaServerConf['sync_libraries']>
|
||||
switchs: unknown[]
|
||||
}
|
||||
notification: {
|
||||
type: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
config: any
|
||||
switchs: any[]
|
||||
config: NotificationConf['config']
|
||||
switchs: NonNullable<NotificationConf['switchs']>
|
||||
}
|
||||
agent: {
|
||||
enabled: boolean
|
||||
@@ -162,7 +162,7 @@ function normalizeThinkingLevelValue(value?: unknown) {
|
||||
return aliasMap[normalized] || normalized
|
||||
}
|
||||
|
||||
function resolveThinkingLevelValue(data?: Record<string, any>) {
|
||||
function resolveThinkingLevelValue(data?: Record<string, unknown>) {
|
||||
const explicit = normalizeThinkingLevelValue(data?.LLM_THINKING_LEVEL)
|
||||
if (explicit) return explicit
|
||||
|
||||
@@ -179,6 +179,9 @@ const totalSteps = 8
|
||||
// 加载状态
|
||||
const isLoading = ref(false)
|
||||
|
||||
// 保存、连通性测试和完成状态写入共享同一个提交门禁。
|
||||
const isActionPending = ref(false)
|
||||
|
||||
// 选中的预设规则
|
||||
const selectedPreset = ref('')
|
||||
|
||||
@@ -477,10 +480,6 @@ export function useSetupWizard() {
|
||||
wizardData.value.preferences.resolution = '2160p'
|
||||
break
|
||||
case 'balanced':
|
||||
wizardData.value.preferences.quality = '1080P'
|
||||
wizardData.value.preferences.subtitle = 'chinese'
|
||||
wizardData.value.preferences.resolution = '1080p'
|
||||
break
|
||||
case 'chinese':
|
||||
wizardData.value.preferences.quality = '1080P'
|
||||
wizardData.value.preferences.subtitle = 'chinese'
|
||||
@@ -639,7 +638,11 @@ export function useSetupWizard() {
|
||||
errors.push(t('mediaserver.apiKeyRequired'))
|
||||
validationErrors.value.mediaServer.apikey = true
|
||||
}
|
||||
} else if (wizardData.value.mediaServer.type === 'zspace') {
|
||||
} else if (
|
||||
wizardData.value.mediaServer.type === 'zspace' ||
|
||||
wizardData.value.mediaServer.type === 'trimemedia' ||
|
||||
wizardData.value.mediaServer.type === 'ugreen'
|
||||
) {
|
||||
if (!wizardData.value.mediaServer.config?.username?.trim()) {
|
||||
errors.push(t('mediaserver.usernameRequired'))
|
||||
validationErrors.value.mediaServer.username = true
|
||||
@@ -653,15 +656,6 @@ export function useSetupWizard() {
|
||||
errors.push(t('mediaserver.tokenRequired'))
|
||||
validationErrors.value.mediaServer.token = true
|
||||
}
|
||||
} else if (wizardData.value.mediaServer.type === 'trimemedia' || wizardData.value.mediaServer.type === 'ugreen') {
|
||||
if (!wizardData.value.mediaServer.config?.username?.trim()) {
|
||||
errors.push(t('mediaserver.usernameRequired'))
|
||||
validationErrors.value.mediaServer.username = true
|
||||
}
|
||||
if (!wizardData.value.mediaServer.config?.password?.trim()) {
|
||||
errors.push(t('mediaserver.passwordRequired'))
|
||||
validationErrors.value.mediaServer.password = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -958,7 +952,7 @@ export function useSetupWizard() {
|
||||
connectivityTest.value.testMessage = testResult.message || t('setupWizard.connectivityTestFailed')
|
||||
}
|
||||
|
||||
// 成功时2秒后隐藏结果,失败时保持显示直到用户操作
|
||||
// 成功时立即收起结果,失败时保持显示以便用户判断和重试。
|
||||
if (testResult.success) {
|
||||
connectivityTest.value.showResult = false
|
||||
connectivityTest.value.testResult = null
|
||||
@@ -1094,8 +1088,12 @@ export function useSetupWizard() {
|
||||
}
|
||||
}
|
||||
|
||||
// 下一步
|
||||
async function nextStep() {
|
||||
/**
|
||||
* 保存并验证动作开始时所属的步骤。
|
||||
*
|
||||
* 异步请求返回后只有步骤仍未变化才允许继续,避免迟到结果推进另一个步骤。
|
||||
*/
|
||||
async function runStepAction(step: number) {
|
||||
// 验证当前步骤的必输项
|
||||
const validation = validateCurrentStep()
|
||||
if (!validation.isValid) {
|
||||
@@ -1107,31 +1105,46 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
// 保存当前步骤的设置
|
||||
const saved = await saveCurrentStepSettings()
|
||||
if (!saved) {
|
||||
const saved = await saveCurrentStepSettings(step)
|
||||
if (!saved || currentStep.value !== step) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否需要进行测试
|
||||
const needsTest = shouldPerformTest(currentStep.value)
|
||||
const needsTest = shouldPerformTest(step)
|
||||
if (needsTest) {
|
||||
const testResult = await testConnectivity(currentStep.value)
|
||||
if (!testResult) {
|
||||
const testResult = await testConnectivity(step)
|
||||
if (!testResult || currentStep.value !== step) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不是最后一步,则前进到下一步
|
||||
if (currentStep.value < totalSteps) {
|
||||
currentStep.value++
|
||||
if (step < totalSteps) {
|
||||
currentStep.value = step + 1
|
||||
connectivityTest.value.showResult = false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// 下一步
|
||||
async function nextStep() {
|
||||
if (isActionPending.value) return false
|
||||
|
||||
isActionPending.value = true
|
||||
const step = currentStep.value
|
||||
try {
|
||||
return await runStepAction(step)
|
||||
} finally {
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 上一步
|
||||
function prevStep() {
|
||||
if (isActionPending.value) return
|
||||
|
||||
if (currentStep.value > 1) {
|
||||
currentStep.value--
|
||||
}
|
||||
@@ -1139,9 +1152,9 @@ export function useSetupWizard() {
|
||||
}
|
||||
|
||||
// 保存当前步骤的设置
|
||||
async function saveCurrentStepSettings() {
|
||||
async function saveCurrentStepSettings(step: number) {
|
||||
try {
|
||||
switch (currentStep.value) {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return await saveBasicSettings()
|
||||
case 2:
|
||||
@@ -1169,9 +1182,12 @@ export function useSetupWizard() {
|
||||
|
||||
// 完成向导
|
||||
async function completeWizard() {
|
||||
if (isActionPending.value) return
|
||||
|
||||
isActionPending.value = true
|
||||
const step = currentStep.value
|
||||
try {
|
||||
// 先处理下一步(保存当前步骤设置)
|
||||
const saved = await nextStep()
|
||||
const saved = await runStepAction(step)
|
||||
if (!saved) {
|
||||
return
|
||||
}
|
||||
@@ -1183,6 +1199,8 @@ export function useSetupWizard() {
|
||||
} catch (error) {
|
||||
console.error('Setup wizard failed:', error)
|
||||
$toast.error(t('setupWizard.failed'))
|
||||
} finally {
|
||||
isActionPending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1193,27 +1211,19 @@ export function useSetupWizard() {
|
||||
// 获取当前用户信息
|
||||
const currentUser: User = await api.get('user/current')
|
||||
|
||||
if (currentUser) {
|
||||
// 更新现有用户的密码
|
||||
const userData = {
|
||||
name: wizardData.value.basic.username,
|
||||
password: wizardData.value.basic.password,
|
||||
is_active: currentUser.is_active,
|
||||
is_superuser: currentUser.is_superuser,
|
||||
}
|
||||
|
||||
await api.put(`user/${currentUser.id}`, userData)
|
||||
} else {
|
||||
// 如果用户不存在,创建新用户(通常不会发生)
|
||||
const userData = {
|
||||
name: wizardData.value.basic.username,
|
||||
password: wizardData.value.basic.password,
|
||||
is_active: true,
|
||||
is_superuser: true,
|
||||
}
|
||||
|
||||
await api.post('user/', userData)
|
||||
}
|
||||
// 用户更新是完整资料契约,所有现有资料必须随新密码原样提交。
|
||||
await api.put('user/', {
|
||||
id: currentUser.id,
|
||||
name: wizardData.value.basic.username,
|
||||
email: currentUser.email,
|
||||
password: wizardData.value.basic.password,
|
||||
is_active: currentUser.is_active,
|
||||
is_superuser: currentUser.is_superuser,
|
||||
avatar: currentUser.avatar,
|
||||
is_otp: currentUser.is_otp,
|
||||
permissions: currentUser.permissions,
|
||||
settings: currentUser.settings,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update user password failed:', error)
|
||||
throw error
|
||||
@@ -1349,7 +1359,7 @@ export function useSetupWizard() {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// 没有选择下载器时,清空现有配置
|
||||
// 未选择下载器时跳过保存,保留现有配置。
|
||||
console.log('No downloader selected, skipping save')
|
||||
return true
|
||||
}
|
||||
@@ -1379,7 +1389,7 @@ export function useSetupWizard() {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// 没有选择媒体服务器时,清空现有配置
|
||||
// 未选择媒体服务器时跳过保存,保留现有配置。
|
||||
console.log('No media server selected, skipping save')
|
||||
return true
|
||||
}
|
||||
@@ -1409,7 +1419,7 @@ export function useSetupWizard() {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
// 没有选择通知时,清空现有配置
|
||||
// 未选择通知时跳过保存,保留现有配置。
|
||||
console.log('No notification selected, skipping save')
|
||||
return true
|
||||
}
|
||||
@@ -1470,22 +1480,18 @@ export function useSetupWizard() {
|
||||
try {
|
||||
// 如果有自定义规则序列,保存到用户过滤规则组
|
||||
if (wizardData.value.preferences.ruleSequences && wizardData.value.preferences.ruleSequences.length > 0) {
|
||||
try {
|
||||
// 保存当前选中的规则组到 UserFilterRuleGroups
|
||||
await api.post<null>('system/setting/UserFilterRuleGroups', wizardData.value.preferences.ruleSequences, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
// 保存规则组名称到其他设置
|
||||
const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => [rule.name])
|
||||
// 保存当前选中的规则组到 UserFilterRuleGroups
|
||||
await api.post<null>('system/setting/UserFilterRuleGroups', wizardData.value.preferences.ruleSequences, {
|
||||
feedback: 'silent',
|
||||
})
|
||||
// 订阅与洗版设置只引用规则组名称,不存储完整规则或嵌套数组。
|
||||
const ruleGroupNames = wizardData.value.preferences.ruleSequences.map(rule => rule.name)
|
||||
|
||||
// 保存到 SubscribeFilterRuleGroups
|
||||
await api.post<null>('system/setting/SubscribeFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
||||
// 保存到 SubscribeFilterRuleGroups
|
||||
await api.post<null>('system/setting/SubscribeFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
||||
|
||||
// 保存到 BestVersionFilterRuleGroups
|
||||
await api.post<null>('system/setting/BestVersionFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
||||
} catch (error) {
|
||||
console.error('Save rule sequences failed:', error)
|
||||
}
|
||||
// 保存到 BestVersionFilterRuleGroups
|
||||
await api.post<null>('system/setting/BestVersionFilterRuleGroups', ruleGroupNames, { feedback: 'silent' })
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -1588,7 +1594,7 @@ export function useSetupWizard() {
|
||||
// 加载用户站点认证设置
|
||||
async function loadSiteAuthSettings() {
|
||||
try {
|
||||
const result = await api.get<{ value?: { site?: string; params?: Record<string, any> } }>(
|
||||
const result = await api.get<{ value?: { site?: string; params?: Record<string, string | number> } }>(
|
||||
'system/setting/UserSiteAuthParams',
|
||||
)
|
||||
if (result.value) {
|
||||
@@ -1603,7 +1609,9 @@ export function useSetupWizard() {
|
||||
// 加载存储设置
|
||||
async function loadStorageSettings() {
|
||||
try {
|
||||
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/public/Directories')
|
||||
const result = await api.get<{ value?: Array<Partial<TransferDirectoryConf>> }>(
|
||||
'system/setting/public/Directories',
|
||||
)
|
||||
if (result.value?.length) {
|
||||
const directory = result.value[0]
|
||||
wizardData.value.storage.downloadPath = directory.download_path || ''
|
||||
@@ -1619,7 +1627,9 @@ export function useSetupWizard() {
|
||||
// 加载下载器设置
|
||||
async function loadDownloaderSettings() {
|
||||
try {
|
||||
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/Downloaders')
|
||||
const result = await api.get<{ value?: Array<Pick<DownloaderConf, 'type' | 'name' | 'config'>> }>(
|
||||
'system/setting/Downloaders',
|
||||
)
|
||||
if (result.value?.length) {
|
||||
const downloader = result.value[0]
|
||||
wizardData.value.downloader.type = downloader.type
|
||||
@@ -1634,7 +1644,9 @@ export function useSetupWizard() {
|
||||
// 加载媒体服务器设置
|
||||
async function loadMediaServerSettings() {
|
||||
try {
|
||||
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/MediaServers')
|
||||
const result = await api.get<{
|
||||
value?: Array<Pick<MediaServerConf, 'type' | 'name' | 'config' | 'sync_libraries'>>
|
||||
}>('system/setting/MediaServers')
|
||||
if (result.value?.length) {
|
||||
const mediaServer = result.value[0]
|
||||
wizardData.value.mediaServer.type = mediaServer.type
|
||||
@@ -1650,7 +1662,9 @@ export function useSetupWizard() {
|
||||
// 加载通知设置
|
||||
async function loadNotificationSettings() {
|
||||
try {
|
||||
const result = await api.get<{ value?: Array<Record<string, any>> }>('system/setting/Notifications')
|
||||
const result = await api.get<{
|
||||
value?: Array<Pick<NotificationConf, 'type' | 'name' | 'enabled' | 'config' | 'switchs'>>
|
||||
}>('system/setting/Notifications')
|
||||
if (result.value?.length) {
|
||||
const notification = result.value[0]
|
||||
wizardData.value.notification.type = notification.type
|
||||
@@ -1692,6 +1706,7 @@ export function useSetupWizard() {
|
||||
connectivityTest,
|
||||
validationErrors,
|
||||
isLoading,
|
||||
isActionPending,
|
||||
|
||||
// 方法
|
||||
createRandomString,
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import SetupPage from '@/pages/setup.vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, type Ref } from 'vue'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
completeWizard: vi.fn(),
|
||||
initialize: vi.fn(),
|
||||
nextStep: vi.fn(),
|
||||
prevStep: vi.fn(),
|
||||
routerPush: vi.fn(),
|
||||
state: undefined as
|
||||
| undefined
|
||||
| {
|
||||
connectivityTest: Ref<{ isTesting: boolean }>
|
||||
currentStep: ReturnType<typeof ref<number>>
|
||||
isActionPending: ReturnType<typeof ref<boolean>>
|
||||
isLoading: ReturnType<typeof ref<boolean>>
|
||||
stepTitles: ReturnType<typeof ref<string[]>>
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useSetupWizard', () => ({
|
||||
useSetupWizard: () => ({
|
||||
completeWizard: mocks.completeWizard,
|
||||
connectivityTest: mocks.state!.connectivityTest,
|
||||
currentStep: mocks.state!.currentStep,
|
||||
initialize: mocks.initialize,
|
||||
isActionPending: mocks.state!.isActionPending,
|
||||
isLoading: mocks.state!.isLoading,
|
||||
nextStep: mocks.nextStep,
|
||||
prevStep: mocks.prevStep,
|
||||
stepTitles: mocks.state!.stepTitles,
|
||||
totalSteps: 8,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vue-i18n')>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vue-router')>()),
|
||||
useRouter: () => ({ push: mocks.routerPush }),
|
||||
}))
|
||||
|
||||
vi.mock('vuetify', async importOriginal => ({
|
||||
...(await importOriginal<typeof import('vuetify')>()),
|
||||
useDisplay: () => ({ mdAndUp: ref(true), smAndDown: ref(false) }),
|
||||
}))
|
||||
|
||||
const ButtonStub = defineComponent({
|
||||
inheritAttrs: false,
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
icon: String,
|
||||
prependIcon: String,
|
||||
},
|
||||
emits: ['click'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'aria-label': props.icon || props.prependIcon,
|
||||
disabled: props.disabled,
|
||||
onClick: () => emit('click'),
|
||||
},
|
||||
slots.default?.(),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const PassThrough = defineComponent({
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h('div', slots.default?.()),
|
||||
})
|
||||
|
||||
const StepperItemStub = defineComponent({
|
||||
setup:
|
||||
(_, { slots }) =>
|
||||
() =>
|
||||
h('div', { 'data-testid': 'step-title' }, slots.title?.()),
|
||||
})
|
||||
|
||||
const StepperStub = defineComponent({
|
||||
props: { modelValue: Number },
|
||||
emits: ['update:modelValue'],
|
||||
setup(props, { emit, slots }) {
|
||||
return () =>
|
||||
h('div', [
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
'data-testid': 'stepper-update',
|
||||
onClick: () => emit('update:modelValue', (props.modelValue ?? 1) + 1),
|
||||
},
|
||||
'update step',
|
||||
),
|
||||
slots.default?.(),
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
function stepStub(name: string) {
|
||||
return defineComponent({
|
||||
name,
|
||||
setup: () => () => h('section', { 'data-step': name }, name),
|
||||
})
|
||||
}
|
||||
|
||||
const globalStubs = {
|
||||
AgentSettingsStep: stepStub('AgentSettingsStep'),
|
||||
BasicSettingsStep: stepStub('BasicSettingsStep'),
|
||||
ConnectivityTest: stepStub('ConnectivityTest'),
|
||||
DownloaderSettingsStep: stepStub('DownloaderSettingsStep'),
|
||||
MediaServerSettingsStep: stepStub('MediaServerSettingsStep'),
|
||||
NotificationSettingsStep: stepStub('NotificationSettingsStep'),
|
||||
PreferencesSettingsStep: stepStub('PreferencesSettingsStep'),
|
||||
SiteAuthSettingsStep: stepStub('SiteAuthSettingsStep'),
|
||||
StorageSettingsStep: stepStub('StorageSettingsStep'),
|
||||
VBtn: ButtonStub,
|
||||
VCard: PassThrough,
|
||||
VCardActions: PassThrough,
|
||||
VCardText: PassThrough,
|
||||
VDivider: true,
|
||||
VProgressCircular: stepStub('LoadingIndicator'),
|
||||
VStepper: StepperStub,
|
||||
VStepperHeader: PassThrough,
|
||||
VStepperItem: StepperItemStub,
|
||||
VStepperWindow: PassThrough,
|
||||
VStepperWindowItem: PassThrough,
|
||||
}
|
||||
|
||||
function mountPage() {
|
||||
return mount(SetupPage, { global: { stubs: globalStubs } })
|
||||
}
|
||||
|
||||
describe('setup page orchestration', () => {
|
||||
beforeEach(() => {
|
||||
mocks.completeWizard.mockReset()
|
||||
mocks.initialize.mockReset()
|
||||
mocks.nextStep.mockReset()
|
||||
mocks.prevStep.mockReset()
|
||||
mocks.routerPush.mockReset()
|
||||
mocks.initialize.mockResolvedValue(undefined)
|
||||
mocks.state = {
|
||||
connectivityTest: ref({ isTesting: false }),
|
||||
currentStep: ref(1),
|
||||
isActionPending: ref(false),
|
||||
isLoading: ref(false),
|
||||
stepTitles: ref(Array.from({ length: 8 }, (_, index) => `step-${index + 1}`)),
|
||||
}
|
||||
})
|
||||
|
||||
it('initializes and renders the loading state instead of setup steps', async () => {
|
||||
mocks.state!.isLoading.value = true
|
||||
const wrapper = mountPage()
|
||||
|
||||
expect(mocks.initialize).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('setupWizard.loading')
|
||||
expect(wrapper.find('[data-step="LoadingIndicator"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[data-step="BasicSettingsStep"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('composes all eight step components without duplicating their business templates', () => {
|
||||
const wrapper = mountPage()
|
||||
|
||||
expect(wrapper.findAll('[data-step$="SettingsStep"]').map(node => node.attributes('data-step'))).toEqual([
|
||||
'BasicSettingsStep',
|
||||
'SiteAuthSettingsStep',
|
||||
'StorageSettingsStep',
|
||||
'DownloaderSettingsStep',
|
||||
'MediaServerSettingsStep',
|
||||
'NotificationSettingsStep',
|
||||
'AgentSettingsStep',
|
||||
'PreferencesSettingsStep',
|
||||
])
|
||||
expect(wrapper.text()).toContain('step-1')
|
||||
expect(wrapper.text()).toContain('step-8')
|
||||
})
|
||||
|
||||
it('keeps the current step synchronized with stepper model updates', async () => {
|
||||
const wrapper = mountPage()
|
||||
|
||||
await wrapper.get('[data-testid="stepper-update"]').trigger('click')
|
||||
|
||||
expect(mocks.state!.currentStep.value).toBe(2)
|
||||
})
|
||||
|
||||
it('routes header commands to settings and home', async () => {
|
||||
const wrapper = mountPage()
|
||||
|
||||
await wrapper.get('button[aria-label="mdi-cog"]').trigger('click')
|
||||
await wrapper.get('button[aria-label="mdi-close"]').trigger('click')
|
||||
|
||||
expect(mocks.routerPush.mock.calls).toEqual([['/setting'], ['/']])
|
||||
})
|
||||
|
||||
it('delegates previous and next while the action is idle', async () => {
|
||||
mocks.state!.currentStep.value = 4
|
||||
const wrapper = mountPage()
|
||||
|
||||
await wrapper.get('button[aria-label="mdi-chevron-left"]').trigger('click')
|
||||
const next = wrapper.findAll('button').find(button => button.text().includes('common.next'))
|
||||
expect(next).toBeDefined()
|
||||
await next!.trigger('click')
|
||||
|
||||
expect(mocks.prevStep).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.nextStep).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('disables previous and next for the entire pending action, including save time', async () => {
|
||||
mocks.state!.currentStep.value = 4
|
||||
mocks.state!.isActionPending.value = true
|
||||
const wrapper = mountPage()
|
||||
const previous = wrapper.get('button[aria-label="mdi-chevron-left"]')
|
||||
const next = wrapper.findAll('button').find(button => button.text().includes('common.next'))
|
||||
|
||||
expect(previous.attributes('disabled')).toBeDefined()
|
||||
expect(next).toBeDefined()
|
||||
expect(next!.attributes('disabled')).toBeDefined()
|
||||
await previous.trigger('click')
|
||||
await next!.trigger('click')
|
||||
expect(mocks.prevStep).not.toHaveBeenCalled()
|
||||
expect(mocks.nextStep).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('labels the pending next action as connectivity testing while its test is running', () => {
|
||||
mocks.state!.currentStep.value = 4
|
||||
mocks.state!.isActionPending.value = true
|
||||
mocks.state!.connectivityTest.value.isTesting = true
|
||||
|
||||
const wrapper = mountPage()
|
||||
|
||||
expect(wrapper.text()).toContain('setupWizard.testing')
|
||||
expect(wrapper.text()).not.toContain('common.next')
|
||||
})
|
||||
|
||||
it('disables complete for the entire pending action and delegates it when idle', async () => {
|
||||
mocks.state!.currentStep.value = 8
|
||||
mocks.state!.isActionPending.value = true
|
||||
const pendingWrapper = mountPage()
|
||||
const pendingComplete = pendingWrapper.get('button[aria-label="mdi-check"]')
|
||||
expect(pendingComplete.attributes('disabled')).toBeDefined()
|
||||
await pendingComplete.trigger('click')
|
||||
expect(mocks.completeWizard).not.toHaveBeenCalled()
|
||||
|
||||
pendingWrapper.unmount()
|
||||
mocks.state!.isActionPending.value = false
|
||||
const idleWrapper = mountPage()
|
||||
await idleWrapper.get('button[aria-label="mdi-check"]').trigger('click')
|
||||
expect(mocks.completeWizard).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
+4
-9
@@ -30,6 +30,7 @@ const {
|
||||
completeWizard,
|
||||
initialize,
|
||||
isLoading,
|
||||
isActionPending,
|
||||
} = useSetupWizard()
|
||||
|
||||
// 初始化
|
||||
@@ -149,7 +150,7 @@ onMounted(async () => {
|
||||
v-if="currentStep !== 1"
|
||||
prepend-icon="mdi-chevron-left"
|
||||
@click="prevStep"
|
||||
:disabled="connectivityTest.isTesting"
|
||||
:disabled="isActionPending"
|
||||
>
|
||||
{{ t('common.previous') }}
|
||||
</VBtn>
|
||||
@@ -161,17 +162,11 @@ onMounted(async () => {
|
||||
color="primary"
|
||||
append-icon="mdi-chevron-right"
|
||||
@click="nextStep"
|
||||
:disabled="connectivityTest.isTesting"
|
||||
:disabled="isActionPending"
|
||||
>
|
||||
{{ connectivityTest.isTesting ? t('setupWizard.testing') : t('common.next') }}
|
||||
</VBtn>
|
||||
<VBtn
|
||||
v-else
|
||||
color="success"
|
||||
prepend-icon="mdi-check"
|
||||
@click="completeWizard"
|
||||
:disabled="connectivityTest.isTesting"
|
||||
>
|
||||
<VBtn v-else color="success" prepend-icon="mdi-check" @click="completeWizard" :disabled="isActionPending">
|
||||
{{ t('setupWizard.complete') }}
|
||||
</VBtn>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user