From 6595ed0dfc0abfb190fe966dec4c5c144f0c0706 Mon Sep 17 00:00:00 2001 From: cat-shark <1716967236@qq.com> Date: Mon, 3 Aug 2026 22:21:36 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E5=8D=95=E5=85=83=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E7=9B=B4=E6=8E=A5=E6=B5=8B=E8=AF=95=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/components/Login.spec.ts | 149 ++++---- src/__tests__/components/MyHead.spec.ts | 134 +++---- src/__tests__/components/StartTask.spec.ts | 409 +++++++++------------ src/__tests__/components/TaskForm.spec.ts | 272 +++++++------- src/__tests__/composables/fragment.spec.ts | 144 ++------ src/__tests__/composables/useTimer.spec.ts | 102 ++--- src/__tests__/router/guard.spec.ts | 72 +--- src/__tests__/setup.ts | 39 +- src/__tests__/utils/request.spec.ts | 9 - 9 files changed, 522 insertions(+), 808 deletions(-) diff --git a/src/__tests__/components/Login.spec.ts b/src/__tests__/components/Login.spec.ts index 2115001..9ae44f0 100644 --- a/src/__tests__/components/Login.spec.ts +++ b/src/__tests__/components/Login.spec.ts @@ -1,112 +1,91 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { mount } from '@vue/test-utils' -import { createRouter, createMemoryHistory } from 'vue-router' -import { reactive, ref } from 'vue' +import ElementPlus from 'element-plus' +import { ElMessage } from 'element-plus' +import Login from '@/components/Login.vue' +import router from '@/router' +import { login } from '@/api/login' -// Mock API vi.mock('@/api/login', () => ({ login: vi.fn(), logout: vi.fn(), })) -// Mock Element Plus -vi.mock('element-plus', () => ({ - ElMessage: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - }, -})) - -import { login } from '@/api/login' -import { ElMessage } from 'element-plus' - -function createTestRouter() { - return createRouter({ - history: createMemoryHistory(), - routes: [ - { path: '/login', component: { template: '
' } }, - { path: '/welcome', component: { template: '
' } }, - ], +function mountLogin() { + return mount(Login, { + shallow: true, + global: { + plugins: [ElementPlus, router], + }, }) } -describe('Login.vue logic', () => { - beforeEach(() => { +function createForm(valid = true) { + return { + validate: (callback: (valid: boolean) => void) => callback(valid), + } as any +} + +describe('Login.vue', () => { + beforeEach(async () => { vi.clearAllMocks() localStorage.clear() + await router.push('/login') + await router.isReady() }) - describe('isNotEmpty validator', () => { - it('calls callback with error message when value is empty', () => { - const callback = vi.fn() - const rule = { message: '请输入账号!' } - // isNotEmpty logic - const value = '' - if (!value) { - callback(rule.message) - } else { - callback() - } - expect(callback).toHaveBeenCalledWith('请输入账号!') - }) + it('isNotEmpty validator reports empty value and accepts filled value', () => { + const wrapper = mountLogin() + const callback = vi.fn() - it('calls callback without error when value is present', () => { - const callback = vi.fn() - const rule = { message: '请输入账号!' } - const value = 'admin' - if (!value) { - callback(rule.message) - } else { - callback() - } - expect(callback).toHaveBeenCalledWith() - }) + wrapper.vm.isNotEmpty({ message: '请输入账号!' } as any, '', callback) + expect(callback).toHaveBeenCalledWith('请输入账号!') + + wrapper.vm.isNotEmpty({ message: '请输入账号!' } as any, 'admin', callback) + expect(callback).toHaveBeenCalledWith() }) - describe('submitForm logic', () => { - it('sets localStorage and navigates on successful login', async () => { - vi.mocked(login).mockResolvedValue({ code: 200, message: '登录成功' } as any) - const router = createTestRouter() - await router.push('/login') - await router.isReady() + it('sets localStorage and navigates on successful login', async () => { + vi.mocked(login).mockResolvedValue({ code: 200, message: '登录成功' } as any) + const wrapper = mountLogin() + wrapper.vm.registerData.username = 'admin' + wrapper.vm.registerData.password = '123456' - // Simulate submitForm logic - const username = 'admin' - const password = '123456' - const result = await login(username, password) + await wrapper.vm.submitForm(createForm(true)) - if (result.code === 200) { - localStorage.setItem('isLoggedIn', 'true') - ElMessage.success(result.message) - await router.push('/welcome') - } + expect(localStorage.getItem('isLoggedIn')).toBe('true') + expect(ElMessage.success).toHaveBeenCalledWith('登录成功') + expect(router.currentRoute.value.path).toBe('/welcome') + }) - expect(localStorage.getItem('isLoggedIn')).toBe('true') - expect(ElMessage.success).toHaveBeenCalledWith('登录成功') - expect(router.currentRoute.value.path).toBe('/welcome') - }) + it('shows error message on failed login', async () => { + vi.mocked(login).mockResolvedValue({ code: 401, message: '密码错误' } as any) + const wrapper = mountLogin() + wrapper.vm.registerData.username = 'admin' + wrapper.vm.registerData.password = 'wrong' - it('shows error message on failed login', async () => { - vi.mocked(login).mockResolvedValue({ code: 401, message: '密码错误' } as any) + await wrapper.vm.submitForm(createForm(true)) - const result = await login('admin', 'wrong') - if (result.code !== 200) { - ElMessage.error(result.message) - } + expect(ElMessage.error).toHaveBeenCalledWith('密码错误') + expect(localStorage.getItem('isLoggedIn')).toBeNull() + expect(router.currentRoute.value.path).toBe('/login') + }) - expect(ElMessage.error).toHaveBeenCalledWith('密码错误') - expect(localStorage.getItem('isLoggedIn')).toBeNull() - }) + it('does not submit again while loading', async () => { + let resolveLogin!: (value: any) => void + vi.mocked(login).mockImplementation( + () => new Promise((resolve) => { resolveLogin = resolve }), + ) + const wrapper = mountLogin() - it('does not submit when already loading', async () => { - const loading = ref(true) - // Simulate guard: if (!formEl || loading.value) return; - if (loading.value) { - // Early return - no API call - } - expect(login).not.toHaveBeenCalled() - }) + const firstSubmit = wrapper.vm.submitForm(createForm(true)) + const secondSubmit = wrapper.vm.submitForm(createForm(true)) + await Promise.resolve() + + expect(login).toHaveBeenCalledTimes(1) + + resolveLogin({ code: 200, message: '登录成功' }) + await Promise.all([firstSubmit, secondSubmit]) + expect(localStorage.getItem('isLoggedIn')).toBe('true') }) }) diff --git a/src/__tests__/components/MyHead.spec.ts b/src/__tests__/components/MyHead.spec.ts index 8fc7e6b..addd933 100644 --- a/src/__tests__/components/MyHead.spec.ts +++ b/src/__tests__/components/MyHead.spec.ts @@ -1,106 +1,72 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { computed, ref } from 'vue' +import { mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { ElMessage } from 'element-plus' +import MyHead from '@/components/MyHead.vue' +import router from '@/router' +import { logout } from '@/api/login' vi.mock('@/api/login', () => ({ login: vi.fn(), - logout: vi.fn().mockResolvedValue({ code: 200 }), + logout: vi.fn(), })) -vi.mock('element-plus', () => ({ - ElMessage: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - }, -})) +async function mountAt(path: string) { + await router.push(path) + await router.isReady() + return mount(MyHead, { + shallow: true, + global: { + plugins: [ElementPlus, router], + }, + }) +} -import { logout } from '@/api/login' -import { ElMessage } from 'element-plus' - -describe('MyHead.vue logic', () => { - beforeEach(() => { +describe('MyHead.vue', () => { + beforeEach(async () => { vi.clearAllMocks() localStorage.clear() + await router.push('/login') + await router.isReady() }) - describe('route-based computed properties', () => { - it('isLoginRoute is true when path is /login', () => { - const routePath = ref('/login') - const isLoginRoute = computed(() => routePath.value === '/login') - expect(isLoginRoute.value).toBe(true) - }) + it('derives route-based computed properties from real route', async () => { + const loginWrapper = await mountAt('/login') + expect(loginWrapper.vm.isLoginRoute).toBe(true) + expect(loginWrapper.vm.showBackButton).toBe(false) - it('isLoginRoute is false for other paths', () => { - const routePath = ref('/welcome') - const isLoginRoute = computed(() => routePath.value === '/login') - expect(isLoginRoute.value).toBe(false) - }) + localStorage.setItem('isLoggedIn', 'true') + const studyWrapper = await mountAt('/study') + expect(studyWrapper.vm.isLoginRoute).toBe(false) + expect(studyWrapper.vm.showBackButton).toBe(true) + expect(studyWrapper.vm.pageTitle).toBe('学习任务') - it('showBackButton is true for non-login, non-welcome routes', () => { - const routePath = ref('/study') - const showBackButton = computed(() => routePath.value !== '/login' && routePath.value !== '/welcome') - expect(showBackButton.value).toBe(true) - }) - - it('showBackButton is false on welcome route', () => { - const routePath = ref('/welcome') - const showBackButton = computed(() => routePath.value !== '/login' && routePath.value !== '/welcome') - expect(showBackButton.value).toBe(false) - }) - - it('showBackButton is false on login route', () => { - const routePath = ref('/login') - const showBackButton = computed(() => routePath.value !== '/login' && routePath.value !== '/welcome') - expect(showBackButton.value).toBe(false) - }) - - it('pageTitle is derived from route meta', () => { - const meta = { title: '学习任务' } - const pageTitle = computed(() => meta?.title as string | undefined) - expect(pageTitle.value).toBe('学习任务') - }) - - it('pageTitle is undefined when no meta title', () => { - const meta = {} - const pageTitle = computed(() => meta?.title as string | undefined) - expect(pageTitle.value).toBeUndefined() - }) + const welcomeWrapper = await mountAt('/welcome') + expect(welcomeWrapper.vm.showBackButton).toBe(false) + expect(welcomeWrapper.vm.pageTitle).toBeUndefined() }) - describe('handleLogout logic', () => { - it('clears login state and navigates to login', async () => { - localStorage.setItem('isLoggedIn', 'true') + it('clears login state and navigates to login on logout', async () => { + vi.mocked(logout).mockResolvedValue({ code: 200 } as any) + localStorage.setItem('isLoggedIn', 'true') + const wrapper = await mountAt('/study') - // Simulate handleLogout - try { - await logout() - } catch { - // Backend failure shouldn't block logout - } finally { - localStorage.removeItem('isLoggedIn') - ElMessage.success('已退出登录') - } + await wrapper.vm.handleLogout() - expect(localStorage.getItem('isLoggedIn')).toBeNull() - expect(ElMessage.success).toHaveBeenCalledWith('已退出登录') - }) + expect(localStorage.getItem('isLoggedIn')).toBeNull() + expect(ElMessage.success).toHaveBeenCalledWith('已退出登录') + expect(router.currentRoute.value.path).toBe('/login') + }) - it('clears login state even when API call fails', async () => { - vi.mocked(logout).mockRejectedValue(new Error('Network error')) - localStorage.setItem('isLoggedIn', 'true') + it('clears login state even when logout API fails', async () => { + vi.mocked(logout).mockRejectedValue(new Error('Network error')) + localStorage.setItem('isLoggedIn', 'true') + const wrapper = await mountAt('/study') - try { - await logout() - } catch { - // Expected - } finally { - localStorage.removeItem('isLoggedIn') - ElMessage.success('已退出登录') - } + await wrapper.vm.handleLogout() - expect(localStorage.getItem('isLoggedIn')).toBeNull() - expect(ElMessage.success).toHaveBeenCalledWith('已退出登录') - }) + expect(localStorage.getItem('isLoggedIn')).toBeNull() + expect(ElMessage.success).toHaveBeenCalledWith('已退出登录') + expect(router.currentRoute.value.path).toBe('/login') }) }) diff --git a/src/__tests__/components/StartTask.spec.ts b/src/__tests__/components/StartTask.spec.ts index d42c5f3..fb93cf3 100644 --- a/src/__tests__/components/StartTask.spec.ts +++ b/src/__tests__/components/StartTask.spec.ts @@ -1,276 +1,209 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ref, reactive } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { ElMessage, ElMessageBox } from 'element-plus' +import { createMemoryHistory, createRouter } from 'vue-router' +import StartTask from '@/components/StartTask.vue' +import router from '@/router' +import { + continueSession, + endSession, + getExpectation, + getSessionDetail, + pauseSession, + startOrContinueStudySession, +} from '@/api/studySessions' +import { getFragmentsBySession } from '@/api/reportFragments' -// Mock API vi.mock('@/api/studySessions', () => ({ continueSession: vi.fn(), - pauseSession: vi.fn(), endSession: vi.fn(), - startOrContinueStudySession: vi.fn(), + getExpectation: vi.fn(), + getReportDraft: vi.fn(), getSessionDetail: vi.fn(), + getTaskFragments: vi.fn(), + getTaskReports: vi.fn(), + pauseSession: vi.fn(), + startOrContinueStudySession: vi.fn(), + upsertExpectation: vi.fn(), })) vi.mock('@/api/reportFragments', () => ({ + createFragments: vi.fn(), getFragmentsBySession: vi.fn(), updateFragments: vi.fn(), })) -import { - continueSession, - pauseSession, - endSession, - startOrContinueStudySession, -} from '@/api/studySessions' - -const mockedContinueSession = vi.mocked(continueSession) -const mockedPauseSession = vi.mocked(pauseSession) -const mockedEndSession = vi.mocked(endSession) -const mockedStartOrContinue = vi.mocked(startOrContinueStudySession) - -// 模拟 StartTask.vue 中的核心状态和方法 -function createSessionSimulator() { - const taskInfo = reactive({ - sessionNum: 'SESSION_001', - sessionState: 'ONGOING' as string, - taskName: '测试任务', - taskNum: 'T001', - startTime: '', - endTime: '', - lastStartTime: '', - actualTime: 0, - effectiveTime: 0, - effectivenessRatio: '--', - pointerPosition: 1500000, - systemMessage: '', - }) - - const timerRunning = ref(true) - - const clear = () => { - timerRunning.value = false - } - - const runCountdown = (duration: number) => { - timerRunning.value = true - } - - // 从 StartTask.vue 提取的 stopTimer 逻辑 - const stopTimer = async () => { - const res = await pauseSession(taskInfo.sessionNum) - if (res.code === 200) { - taskInfo.sessionState = 'PAUSED' - } - clear() - } - - // 从 StartTask.vue 提取的 startTimer 逻辑 - const startTimer = async () => { - if (taskInfo.sessionState === 'PAUSED') { - const res = await continueSession(taskInfo.sessionNum) - if (res.code === 200) { - // ElMessage.success("任务继续") - } - // loadTaskSession 模拟 - const sessionRes = await startOrContinueStudySession(taskInfo.taskNum) - Object.assign(taskInfo, sessionRes.data) - } - const duration = taskInfo.pointerPosition || 25 * 60 * 1000 - runCountdown(duration) - } - - // 从 StartTask.vue 提取的 endTimer 逻辑(简化版,跳过 confirm) - const endTimer = async (content: string) => { - if (!content) return - const res = await endSession(taskInfo.sessionNum, content) - if (res.code === 200) { - if (res.message && res.message !== '请求成功') { - // ElMessage.warning(res.message) - } - } - clear() - } - - return { taskInfo, timerRunning, stopTimer, startTimer, endTimer, clear } +const defaultSessionData = { + sessionNum: 'SESSION_001', + sessionState: 'PAUSED', + taskName: '测试任务', + taskNum: 'T001', + taskId: 1, + startTime: '', + endTime: '', + lastStartTime: '', + actualTime: 0, + effectiveTime: 0, + effectivenessRatio: '--', + pointerPosition: 1_500_000, + systemMessage: '', } -describe('StartTask.vue 暂停/继续逻辑', () => { - beforeEach(() => { +async function mountTask() { + localStorage.setItem('isLoggedIn', 'true') + const testRouter = createRouter({ + history: createMemoryHistory(), + routes: [ + { + path: '/start-task/:taskNum', + component: StartTask, + meta: { requiresAuth: true, title: '学习会话' }, + }, + ], + }) + await testRouter.push('/start-task/T001') + await testRouter.isReady() + const wrapper = mount(StartTask, { + shallow: true, + global: { + plugins: [ElementPlus, testRouter], + }, + }) + await flushPromises() + return wrapper +} + +describe('StartTask.vue', () => { + beforeEach(async () => { vi.clearAllMocks() + localStorage.clear() + vi.mocked(startOrContinueStudySession).mockResolvedValue({ code: 200, data: defaultSessionData } as any) + vi.mocked(getExpectation).mockResolvedValue({ code: 200, data: { description: '本次学习预期' } } as any) + vi.mocked(getFragmentsBySession).mockResolvedValue({ code: 200, data: [] } as any) + vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm') + await router.push('/login') + await router.isReady() }) - describe('stopTimer - 暂停', () => { - it('调用 pauseSession API', async () => { - mockedPauseSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - const { stopTimer } = createSessionSimulator() + it('stopTimer pauses real session state and refreshes from detail API', async () => { + vi.mocked(pauseSession).mockResolvedValue({ code: 200, message: '请求成功' } as any) + vi.mocked(getSessionDetail).mockResolvedValue({ + code: 200, + data: { + sessionState: 'PAUSED', + actualTime: 10, + effectiveTime: 20, + effectivenessRatio: 0.8, + pointerPosition: 1_200_000, + }, + } as any) + const wrapper = await mountTask() + wrapper.vm.taskInfo.sessionState = 'ONGOING' + wrapper.vm.timerRunning = true - await stopTimer() + await wrapper.vm.stopTimer() - expect(mockedPauseSession).toHaveBeenCalledWith('SESSION_001') - }) - - it('暂停成功后 sessionState 应更新为 PAUSED', async () => { - mockedPauseSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - const { taskInfo, stopTimer } = createSessionSimulator() - - expect(taskInfo.sessionState).toBe('ONGOING') - await stopTimer() - expect(taskInfo.sessionState).toBe('PAUSED') - }) - - it('暂停后 timerRunning 应为 false', async () => { - mockedPauseSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - const { timerRunning, stopTimer } = createSessionSimulator() - - expect(timerRunning.value).toBe(true) - await stopTimer() - expect(timerRunning.value).toBe(false) - }) + expect(pauseSession).toHaveBeenCalledWith('SESSION_001') + expect(wrapper.vm.taskInfo.sessionState).toBe('PAUSED') + expect(wrapper.vm.taskInfo.actualTime).toBe(10) + expect(wrapper.vm.taskInfo.effectiveTime).toBe(20) + expect(wrapper.vm.timerRunning).toBe(false) + wrapper.unmount() }) - describe('startTimer - 继续', () => { - it('PAUSED 状态下点击开始,应调用 continueSession API', async () => { - mockedPauseSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - mockedContinueSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - mockedStartOrContinue.mockResolvedValue({ - code: 200, - data: { - sessionNum: 'SESSION_001', - sessionState: 'ONGOING', - pointerPosition: 1500000, - }, - } as any) + it('startTimer continues a paused session and reloads session data', async () => { + vi.mocked(continueSession).mockResolvedValue({ code: 200, message: '请求成功' } as any) + vi.mocked(startOrContinueStudySession).mockResolvedValue({ + code: 200, + data: { ...defaultSessionData, sessionState: 'ONGOING', pointerPosition: 1_200_000 }, + } as any) + const wrapper = await mountTask() - const { taskInfo, stopTimer, startTimer } = createSessionSimulator() + // 挂载时后端返回 PAUSED;手动改为 PAUSED 后,startTimer 应触发 continue + wrapper.vm.taskInfo.sessionState = 'PAUSED' + await wrapper.vm.startTimer() - // 先暂停 - await stopTimer() - expect(taskInfo.sessionState).toBe('PAUSED') - - // 再开始 - await startTimer() - - expect(mockedContinueSession).toHaveBeenCalledWith('SESSION_001') - }) - - it('PAUSED 状态下点击开始,应调用 loadTaskSession 刷新数据', async () => { - mockedPauseSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - mockedContinueSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - mockedStartOrContinue.mockResolvedValue({ - code: 200, - data: { - sessionNum: 'SESSION_001', - sessionState: 'ONGOING', - pointerPosition: 1200000, - }, - } as any) - - const { taskInfo, stopTimer, startTimer } = createSessionSimulator() - - await stopTimer() - await startTimer() - - expect(mockedStartOrContinue).toHaveBeenCalledWith('T001') - expect(taskInfo.sessionState).toBe('ONGOING') - }) - - it('ONGOING 状态下点击开始,不应调用 continueSession API', async () => { - mockedStartOrContinue.mockResolvedValue({ - code: 200, - data: { sessionState: 'ONGOING', pointerPosition: 1500000 }, - } as any) - - const { taskInfo, startTimer } = createSessionSimulator() - - // 直接在 ONGOING 状态调用 startTimer - expect(taskInfo.sessionState).toBe('ONGOING') - await startTimer() - - expect(mockedContinueSession).not.toHaveBeenCalled() - }) - - it('开始后 timerRunning 应为 true', async () => { - mockedPauseSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - mockedContinueSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - mockedStartOrContinue.mockResolvedValue({ - code: 200, - data: { sessionState: 'ONGOING', pointerPosition: 1500000 }, - } as any) - - const { timerRunning, stopTimer, startTimer } = createSessionSimulator() - - await stopTimer() - expect(timerRunning.value).toBe(false) - - await startTimer() - expect(timerRunning.value).toBe(true) - }) + expect(continueSession).toHaveBeenCalledWith('SESSION_001') + expect(startOrContinueStudySession).toHaveBeenCalledWith('T001') + expect(wrapper.vm.taskInfo.sessionState).toBe('ONGOING') + expect(wrapper.vm.timerRunning).toBe(true) + wrapper.unmount() }) - describe('endTimer - 结束会话', () => { - it('调用 endSession API 并传递 content', async () => { - mockedEndSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - const { endTimer } = createSessionSimulator() + it('startTimer does not call continue API when session is already ongoing', async () => { + const wrapper = await mountTask() + wrapper.vm.taskInfo.sessionState = 'ONGOING' - await endTimer('学习总结内容') + await wrapper.vm.startTimer() - expect(mockedEndSession).toHaveBeenCalledWith('SESSION_001', '学习总结内容') - }) - - it('后端返回 warning 消息时,message 应包含提示信息', async () => { - mockedEndSession.mockResolvedValue({ - code: 200, - message: '本次有效学习时间不足10分钟,不计入总学习时间', - } as any) - - const { endTimer } = createSessionSimulator() - const res = await endSession('SESSION_001', '内容') - - expect(res.message).toBe('本次有效学习时间不足10分钟,不计入总学习时间') - expect(res.message).not.toBe('请求成功') - }) - - it('content 为空时不调用 API', async () => { - const { endTimer } = createSessionSimulator() - - await endTimer('') - - expect(mockedEndSession).not.toHaveBeenCalled() - }) + expect(continueSession).not.toHaveBeenCalled() + expect(wrapper.vm.timerRunning).toBe(true) + wrapper.unmount() }) - describe('完整暂停→继续→暂停流程', () => { - it('多次暂停/继续应正确更新状态和调用 API', async () => { - mockedPauseSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - mockedContinueSession.mockResolvedValue({ code: 200, message: '请求成功' } as any) - mockedStartOrContinue.mockResolvedValue({ - code: 200, - data: { sessionState: 'ONGOING', pointerPosition: 1500000 }, - } as any) + it('endTimer refuses empty content without calling API', async () => { + const wrapper = await mountTask() - const { taskInfo, stopTimer, startTimer } = createSessionSimulator() + await wrapper.vm.endTimer('') - // 初始状态 - expect(taskInfo.sessionState).toBe('ONGOING') + expect(ElMessage.warning).toHaveBeenCalledWith('请输入学习总结内容') + expect(endSession).not.toHaveBeenCalled() + wrapper.unmount() + }) - // 第一次暂停 - await stopTimer() - expect(taskInfo.sessionState).toBe('PAUSED') - expect(mockedPauseSession).toHaveBeenCalledTimes(1) + it('endTimer ends session and navigates to study page', async () => { + vi.mocked(endSession).mockResolvedValue({ code: 200, message: '请求成功' } as any) + const wrapper = await mountTask() - // 继续 - await startTimer() - expect(taskInfo.sessionState).toBe('ONGOING') - expect(mockedContinueSession).toHaveBeenCalledTimes(1) + await wrapper.vm.endTimer('学习总结内容') - // 第二次暂停 - await stopTimer() - expect(taskInfo.sessionState).toBe('PAUSED') - expect(mockedPauseSession).toHaveBeenCalledTimes(2) + expect(endSession).toHaveBeenCalledWith('SESSION_001', '学习总结内容') + expect(ElMessage.success).toHaveBeenCalledWith('任务结束') + expect(localStorage.getItem('activeSession')).toBeNull() + expect(router.currentRoute.value.path).toBe('/study') + wrapper.unmount() + }) - // 再次继续 - await startTimer() - expect(taskInfo.sessionState).toBe('ONGOING') - expect(mockedContinueSession).toHaveBeenCalledTimes(2) - }) + it('endTimer shows warning message returned by backend', async () => { + vi.mocked(endSession).mockResolvedValue({ + code: 200, + message: '本次有效学习时间不足10分钟,不计入总学习时间', + } as any) + const wrapper = await mountTask() + + await wrapper.vm.endTimer('学习总结内容') + + expect(ElMessage.warning).toHaveBeenCalledWith('本次有效学习时间不足10分钟,不计入总学习时间') + wrapper.unmount() + }) + + it('supports a full pause -> continue -> pause flow', async () => { + vi.mocked(pauseSession).mockResolvedValue({ code: 200, message: '请求成功' } as any) + vi.mocked(getSessionDetail).mockResolvedValue({ + code: 200, + data: { sessionState: 'PAUSED', actualTime: 10, effectiveTime: 20 }, + } as any) + vi.mocked(continueSession).mockResolvedValue({ code: 200, message: '请求成功' } as any) + vi.mocked(startOrContinueStudySession).mockResolvedValue({ + code: 200, + data: { ...defaultSessionData, sessionState: 'ONGOING', pointerPosition: 1_200_000 }, + } as any) + const wrapper = await mountTask() + + wrapper.vm.taskInfo.sessionState = 'ONGOING' + wrapper.vm.timerRunning = true + await wrapper.vm.stopTimer() + expect(wrapper.vm.taskInfo.sessionState).toBe('PAUSED') + expect(pauseSession).toHaveBeenCalledTimes(1) + + await wrapper.vm.startTimer() + expect(wrapper.vm.taskInfo.sessionState).toBe('ONGOING') + expect(continueSession).toHaveBeenCalledTimes(1) + + await wrapper.vm.stopTimer() + expect(wrapper.vm.taskInfo.sessionState).toBe('PAUSED') + expect(pauseSession).toHaveBeenCalledTimes(2) + wrapper.unmount() }) }) diff --git a/src/__tests__/components/TaskForm.spec.ts b/src/__tests__/components/TaskForm.spec.ts index aa59f81..d96da6d 100644 --- a/src/__tests__/components/TaskForm.spec.ts +++ b/src/__tests__/components/TaskForm.spec.ts @@ -1,126 +1,104 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ref, reactive } from 'vue' +import { flushPromises, mount } from '@vue/test-utils' +import ElementPlus from 'element-plus' +import { ElMessage } from 'element-plus' +import TaskForm from '@/components/TaskForm.vue' +import router from '@/router' +import request from '@/utils/request' +import { getTaskApplications } from '@/api/tasks' -vi.mock('element-plus', () => ({ - ElMessage: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), +vi.mock('@/utils/request', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), }, })) -import { ElMessage } from 'element-plus' +vi.mock('@/api/tasks', () => ({ + getTaskApplications: vi.fn(), + createTaskApplication: vi.fn(), + updateTaskApplication: vi.fn(), + deleteTaskApplication: vi.fn(), +})) -describe('TaskForm.vue logic', () => { - beforeEach(() => { +vi.mock('@/utils/fetchTitle', () => ({ + getUrlTitle: vi.fn().mockResolvedValue(''), +})) + +vi.mock('@/utils/markdown', () => ({ + renderMarkdown: vi.fn(() => '

preview

'), +})) + +async function mountAt(path: string) { + localStorage.setItem('isLoggedIn', 'true') + await router.push(path) + await router.isReady() + return mount(TaskForm, { + shallow: true, + global: { + plugins: [ElementPlus, router], + }, + }) +} + +describe('TaskForm.vue', () => { + beforeEach(async () => { vi.clearAllMocks() + localStorage.clear() + vi.mocked(getTaskApplications).mockResolvedValue({ code: 200, data: [] }) + await router.push('/login') + await router.isReady() }) - describe('priorityOptions', () => { - it('contains values 0-5', () => { - const priorityOptions = [0, 1, 2, 3, 4, 5] - expect(priorityOptions).toEqual([0, 1, 2, 3, 4, 5]) - expect(priorityOptions.length).toBe(6) + it('uses real priority options and initial state', async () => { + const wrapper = await mountAt('/add-task') + + expect(wrapper.vm.priorityOptions).toEqual([0, 1, 2, 3, 4, 5]) + expect(wrapper.vm.priority).toEqual({ + urgency: 0, + importance: 0, + contentDifficulty: 0, + futureValue: 0, + subjectivePriority: 0, }) }) - describe('priority state management', () => { - it('initializes with all zeros', () => { - const priority = reactive({ - urgency: 0, - importance: 0, - contentDifficulty: 0, - futureValue: 0, - subjectivePriority: 0, - }) + it('builds create payload from real form state and navigates to study', async () => { + vi.mocked(request.post).mockResolvedValue({ code: 200, message: '创建任务成功' }) + const wrapper = await mountAt('/add-task') + wrapper.vm.taskName = '学习递归' + wrapper.vm.taskDescription = '理解递归的基本概念' + wrapper.vm.materialUrl = 'https://example.com/recursion' + wrapper.vm.priority.urgency = 3 + wrapper.vm.priority.importance = 4 + wrapper.vm.priority.contentDifficulty = 2 + wrapper.vm.priority.futureValue = 5 + wrapper.vm.priority.subjectivePriority = 3 - expect(priority.urgency).toBe(0) - expect(priority.importance).toBe(0) - expect(priority.contentDifficulty).toBe(0) - expect(priority.futureValue).toBe(0) - expect(priority.subjectivePriority).toBe(0) - }) + wrapper.vm.createTask() + await flushPromises() - it('updates individual priority fields', () => { - const priority = reactive({ - urgency: 0, - importance: 0, - contentDifficulty: 0, - futureValue: 0, - subjectivePriority: 0, - }) - - priority.urgency = 3 - priority.importance = 5 - - expect(priority.urgency).toBe(3) - expect(priority.importance).toBe(5) - expect(priority.contentDifficulty).toBe(0) + expect(request.post).toHaveBeenCalledWith('/tasks', { + taskName: '学习递归', + taskDescription: '理解递归的基本概念', + materialUrl: 'https://example.com/recursion', + urgency: 3, + importance: 4, + contentDifficulty: 2, + futureValue: 5, + subjectivePriority: 3, }) + expect(ElMessage.success).toHaveBeenCalledWith('创建任务成功') + await vi.waitFor(() => expect(router.currentRoute.value.name).toBe('study')) }) - describe('task payload construction', () => { - it('builds correct create payload', () => { - const taskName = ref('学习递归') - const taskDescription = ref('理解递归的基本概念') - const materialURL = ref('https://example.com/recursion') - const priority = reactive({ - urgency: 3, - importance: 4, - contentDifficulty: 2, - futureValue: 5, - subjectivePriority: 3, - }) - - const payload = { - taskName: taskName.value, - taskDescription: taskDescription.value, - materialURL: materialURL.value, - ...priority, - } - - expect(payload).toEqual({ - taskName: '学习递归', - taskDescription: '理解递归的基本概念', - materialURL: 'https://example.com/recursion', - urgency: 3, - importance: 4, - contentDifficulty: 2, - futureValue: 5, - subjectivePriority: 3, - }) - }) - - it('builds correct update payload with id', () => { - const taskId = 42 - const taskName = ref('更新后的任务') - const taskDescription = ref('') - const materialURL = ref('') - const priority = reactive({ - urgency: 1, - importance: 2, - contentDifficulty: 0, - futureValue: 3, - subjectivePriority: 4, - }) - - const payload = { - id: taskId, - taskName: taskName.value, - taskDescription: taskDescription.value, - materialURL: materialURL.value, - ...priority, - } - - expect(payload.id).toBe(42) - expect(payload.taskName).toBe('更新后的任务') - }) - }) - - describe('loadTask data mapping', () => { - it('maps API response to form fields correctly', () => { - const apiData = { + it('loads update data from real API and maps it into form state', async () => { + vi.mocked(request.get).mockResolvedValue({ + code: 200, + data: { + taskNum: 'T001', taskName: '学习DP', taskDescription: '动态规划', materialUrl: 'https://example.com/dp', @@ -129,52 +107,54 @@ describe('TaskForm.vue logic', () => { contentDifficulty: 3, futureValue: 4, subjectivePriority: 2, - } - - const taskName = ref('') - const taskDescription = ref('') - const materialURL = ref('') - const priority = reactive({ - urgency: 0, - importance: 0, - contentDifficulty: 0, - futureValue: 0, - subjectivePriority: 0, - }) - - // Simulate loadTask mapping - taskName.value = apiData.taskName - taskDescription.value = apiData.taskDescription - materialURL.value = apiData.materialURL || apiData.materialUrl || '' - priority.urgency = apiData.urgency - priority.importance = apiData.importance - priority.contentDifficulty = apiData.contentDifficulty - priority.futureValue = apiData.futureValue - priority.subjectivePriority = apiData.subjectivePriority - - expect(taskName.value).toBe('学习DP') - expect(materialURL.value).toBe('https://example.com/dp') - expect(priority.urgency).toBe(4) - expect(priority.importance).toBe(5) + }, }) - it('handles materialURL with camelCase variant', () => { - const apiData = { materialURL: 'https://example.com/a', materialUrl: 'https://example.com/b' } - const result = apiData.materialURL || apiData.materialUrl || '' - expect(result).toBe('https://example.com/a') - }) + const wrapper = await mountAt('/update-task/42') + await flushPromises() - it('falls back to empty string when no material URL', () => { - const apiData = {} as any - const result = apiData.materialURL || apiData.materialUrl || '' - expect(result).toBe('') + expect(wrapper.vm.isUpdateMode).toBe(true) + expect(wrapper.vm.taskNum).toBe('T001') + expect(wrapper.vm.taskName).toBe('学习DP') + expect(wrapper.vm.taskDescription).toBe('动态规划') + expect(wrapper.vm.materialUrl).toBe('https://example.com/dp') + expect(wrapper.vm.priority).toEqual({ + urgency: 4, + importance: 5, + contentDifficulty: 3, + futureValue: 4, + subjectivePriority: 2, }) }) - describe('redirect after successful action', () => { - it('navigates to study page after create/update', () => { - const pushTarget = '/study' - expect(pushTarget).toBe('/study') + it('builds update payload with id and navigates to study', async () => { + vi.mocked(request.get).mockResolvedValue({ + code: 200, + data: { + taskNum: 'T001', + taskName: '学习DP', + taskDescription: '动态规划', + materialUrl: '', + urgency: 1, + importance: 2, + contentDifficulty: 0, + futureValue: 3, + subjectivePriority: 4, + }, }) + vi.mocked(request.put).mockResolvedValue({ code: 200, message: '更新任务成功' }) + + const wrapper = await mountAt('/update-task/42') + await flushPromises() + wrapper.vm.taskName = '更新后的任务' + wrapper.vm.updateTask() + await flushPromises() + + expect(request.put).toHaveBeenCalledWith('/tasks/42', expect.objectContaining({ + id: 42, + taskName: '更新后的任务', + })) + expect(ElMessage.success).toHaveBeenCalledWith('更新任务成功') + await vi.waitFor(() => expect(router.currentRoute.value.name).toBe('study')) }) }) diff --git a/src/__tests__/composables/fragment.spec.ts b/src/__tests__/composables/fragment.spec.ts index 9b66244..efc1e9e 100644 --- a/src/__tests__/composables/fragment.spec.ts +++ b/src/__tests__/composables/fragment.spec.ts @@ -1,155 +1,91 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { ref } from 'vue' - -const mockInfo = vi.fn() -const mockSuccess = vi.fn() -const mockError = vi.fn() -const mockWarning = vi.fn() -const mockConfirm = vi.fn() - -vi.mock('element-plus', () => ({ - ElMessage: { - success: mockSuccess, - error: mockError, - warning: mockWarning, - info: mockInfo, - }, - ElMessageBox: { - confirm: (...args: any[]) => mockConfirm(...args), - }, -})) +import { ElMessage, ElMessageBox } from 'element-plus' +import { createFragments } from '@/api/reportFragments' +import { useStudyFragment } from '@/components/composables/fragment' vi.mock('@/api/reportFragments', () => ({ createFragments: vi.fn(), })) -import { createFragments } from '@/api/reportFragments' - -describe('useStudyFragment composable logic', () => { +describe('useStudyFragment', () => { beforeEach(() => { vi.clearAllMocks() - mockConfirm.mockResolvedValue('confirm') + vi.mocked(ElMessageBox.confirm).mockResolvedValue('confirm') }) it('openFragmentDialog resets content and shows dialog', () => { - const fragmentContent = ref('old content') - const fragmentsDialogVisible = ref(false) + const { fragmentsDialogVisible, fragmentContent, openFragmentDialog } = useStudyFragment() + fragmentContent.value = 'old content' - fragmentContent.value = '' - fragmentsDialogVisible.value = true + openFragmentDialog() expect(fragmentContent.value).toBe('') expect(fragmentsDialogVisible.value).toBe(true) }) - it('closeFragmentDialog hides dialog on confirm', async () => { - const fragmentsDialogVisible = ref(true) + it('closeFragmentDialog hides dialog after confirm', async () => { + const { fragmentsDialogVisible, closeFragmentDialog } = useStudyFragment() + fragmentsDialogVisible.value = true - await mockConfirm('确定取消生成吗?', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'warning', - }).then(() => { - fragmentsDialogVisible.value = false - mockInfo('已取消生成') - }) + await closeFragmentDialog() expect(fragmentsDialogVisible.value).toBe(false) - expect(mockInfo).toHaveBeenCalledWith('已取消生成') + expect(ElMessage.info).toHaveBeenCalledWith('已取消生成') }) - it('closeFragmentDialog keeps dialog open on cancel', async () => { - const fragmentsDialogVisible = ref(true) - mockConfirm.mockRejectedValue('cancel') + it('closeFragmentDialog keeps dialog open when user cancels', async () => { + vi.mocked(ElMessageBox.confirm).mockRejectedValue('cancel') + const { fragmentsDialogVisible, closeFragmentDialog } = useStudyFragment() + fragmentsDialogVisible.value = true - try { - await mockConfirm('确定取消生成吗?', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'warning', - }).then(() => { - fragmentsDialogVisible.value = false - }) - } catch { - // User cancelled - } + await closeFragmentDialog() expect(fragmentsDialogVisible.value).toBe(true) }) - it('confirmGenerateFragment warns if content is empty', async () => { - const fragmentContent = ref(' ') + it('confirmGenerateFragment warns when content is empty', async () => { + const { fragmentContent, confirmGenerateFragment } = useStudyFragment() + fragmentContent.value = ' ' - if (!fragmentContent.value.trim()) { - mockWarning('请输入学习内容!') - } + const result = await confirmGenerateFragment('S001') - expect(mockWarning).toHaveBeenCalledWith('请输入学习内容!') + expect(result).toBe(false) + expect(ElMessage.warning).toHaveBeenCalledWith('请输入学习内容!') expect(createFragments).not.toHaveBeenCalled() }) it('confirmGenerateFragment calls API and shows success', async () => { vi.mocked(createFragments).mockResolvedValue({ code: 200 } as any) - const fragmentContent = ref('学习了递归算法') - const fragmentsDialogVisible = ref(true) + const { fragmentContent, fragmentsDialogVisible, confirmGenerateFragment } = useStudyFragment() + fragmentContent.value = '学习了递归算法' - if (fragmentContent.value.trim()) { - await mockConfirm('确定要生成学习残片吗?', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'info', - }).then(async () => { - const res = await createFragments('S001', fragmentContent.value) - if (res.code === 200) { - mockSuccess('学习残片生成成功!') - fragmentsDialogVisible.value = false - } - }) - } + const result = await confirmGenerateFragment('S001') + expect(result).toBe(true) expect(createFragments).toHaveBeenCalledWith('S001', '学习了递归算法') - expect(mockSuccess).toHaveBeenCalledWith('学习残片生成成功!') + expect(ElMessage.success).toHaveBeenCalledWith('学习残片生成成功!') expect(fragmentsDialogVisible.value).toBe(false) }) - it('confirmGenerateFragment handles API failure', async () => { + it('confirmGenerateFragment handles business failure', async () => { vi.mocked(createFragments).mockResolvedValue({ code: 500, message: '生成失败' } as any) - const fragmentContent = ref('学习内容') + const { fragmentContent, confirmGenerateFragment } = useStudyFragment() + fragmentContent.value = '学习内容' - if (fragmentContent.value.trim()) { - await mockConfirm('确定要生成学习残片吗?', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'info', - }).then(async () => { - const res = await createFragments('S001', fragmentContent.value) - if (res.code !== 200) { - mockError(res.message || '生成学习残片失败') - } - }) - } + const result = await confirmGenerateFragment('S001') - expect(mockError).toHaveBeenCalledWith('生成失败') + expect(result).toBe(false) + expect(ElMessage.error).toHaveBeenCalledWith('生成失败') }) it('confirmGenerateFragment handles network error', async () => { vi.mocked(createFragments).mockRejectedValue(new Error('网络超时')) - const fragmentContent = ref('学习内容') + const { fragmentContent, confirmGenerateFragment } = useStudyFragment() + fragmentContent.value = '学习内容' - if (fragmentContent.value.trim()) { - await mockConfirm('确定要生成学习残片吗?', '提示', { - confirmButtonText: '确定', - cancelButtonText: '取消', - type: 'info', - }).then(async () => { - try { - await createFragments('S001', fragmentContent.value) - } catch (error: any) { - mockError(error.message || '请求失败') - } - }) - } + const result = await confirmGenerateFragment('S001') - expect(mockError).toHaveBeenCalledWith('网络超时') + expect(result).toBe(false) + expect(ElMessage.error).toHaveBeenCalledWith('网络超时') }) }) diff --git a/src/__tests__/composables/useTimer.spec.ts b/src/__tests__/composables/useTimer.spec.ts index 694dacc..f05b0f5 100644 --- a/src/__tests__/composables/useTimer.spec.ts +++ b/src/__tests__/composables/useTimer.spec.ts @@ -1,88 +1,56 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' - -// We test the timer composable logic directly since it's a pure Vue composable -// Extract the logic to test it in isolation +import { ElMessageBox } from 'element-plus' +import { useTimer } from '@/components/composables/useTimer' describe('useTimer', () => { beforeEach(() => { vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')) + vi.clearAllMocks() }) afterEach(() => { vi.useRealTimers() }) - // Test the core timer logic that the composable uses - it('formatTime converts seconds to HH:MM:SS', () => { - const formatTime = (totalSeconds: number): string => { - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = totalSeconds % 60 - return [hours, minutes, seconds].map(v => String(v).padStart(2, '0')).join(':') - } + it('syncDisplay writes formatted remaining time', () => { + const { timerMinutes, timerSeconds, timerIsOver, syncDisplay } = useTimer() - expect(formatTime(0)).toBe('00:00:00') - expect(formatTime(61)).toBe('00:01:01') - expect(formatTime(3661)).toBe('01:01:01') - expect(formatTime(59)).toBe('00:00:59') - expect(formatTime(3600)).toBe('01:00:00') + syncDisplay(61_000) + expect(timerMinutes.value).toBe(1) + expect(timerSeconds.value).toBe(1) + expect(timerIsOver.value).toBe(false) + + syncDisplay(0) + expect(timerMinutes.value).toBe(0) + expect(timerSeconds.value).toBe(0) + expect(timerIsOver.value).toBe(true) }) - it('formatTime handles large values', () => { - const formatTime = (totalSeconds: number): string => { - const hours = Math.floor(totalSeconds / 3600) - const minutes = Math.floor((totalSeconds % 3600) / 60) - const seconds = totalSeconds % 60 - return [hours, minutes, seconds].map(v => String(v).padStart(2, '0')).join(':') - } + it('runCountdown decreases remaining time and stops at zero', () => { + const { timerMinutes, timerSeconds, timerRunning, timerIsOver, runCountdown, clear } = useTimer() - expect(formatTime(86399)).toBe('23:59:59') - expect(formatTime(100000)).toBe('27:46:40') + runCountdown(3000) + vi.advanceTimersByTime(1000) + expect(timerMinutes.value).toBe(0) + expect(timerSeconds.value).toBe(2) + expect(timerRunning.value).toBe(true) + + vi.advanceTimersByTime(2000) + expect(timerSeconds.value).toBe(0) + expect(timerRunning.value).toBe(false) + expect(timerIsOver.value).toBe(true) + + clear() }) - it('timer increments elapsedSeconds over time', () => { - let elapsed = 0 - const interval = setInterval(() => { - elapsed++ - }, 1000) + it('notifies when countdown completes', () => { + const { runCountdown, clear } = useTimer() - vi.advanceTimersByTime(3000) - expect(elapsed).toBe(3) + runCountdown(1000) + vi.advanceTimersByTime(1000) - vi.advanceTimersByTime(2000) - expect(elapsed).toBe(5) - - clearInterval(interval) - }) - - it('timer can be paused and resumed', () => { - let elapsed = 0 - let intervalId: ReturnType | null = null - - const start = () => { - if (!intervalId) { - intervalId = setInterval(() => { elapsed++ }, 1000) - } - } - const pause = () => { - if (intervalId) { - clearInterval(intervalId) - intervalId = null - } - } - - start() - vi.advanceTimersByTime(2000) - expect(elapsed).toBe(2) - - pause() - vi.advanceTimersByTime(3000) - expect(elapsed).toBe(2) // Should not change while paused - - start() - vi.advanceTimersByTime(2000) - expect(elapsed).toBe(4) - - pause() + expect(ElMessageBox.alert).toHaveBeenCalled() + clear() }) }) diff --git a/src/__tests__/router/guard.spec.ts b/src/__tests__/router/guard.spec.ts index 5852859..f1e049e 100644 --- a/src/__tests__/router/guard.spec.ts +++ b/src/__tests__/router/guard.spec.ts @@ -1,90 +1,36 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { createRouter, createMemoryHistory } from 'vue-router' - -// We test the guard logic by importing the router and simulating navigation -// The router in src/router/index.ts uses createWebHistory which requires DOM, -// so we'll test the guard logic by recreating the key patterns. +import { describe, it, expect, beforeEach } from 'vitest' +import router from '@/router' describe('route guards', () => { - beforeEach(() => { + beforeEach(async () => { localStorage.clear() - vi.restoreAllMocks() + await router.push('/login') + await router.isReady() }) - function buildRouter() { - return createRouter({ - history: createMemoryHistory(), - routes: [ - { path: '/login', component: { template: '
' }, meta: { requiresAuth: false } }, - { path: '/welcome', component: { template: '
' }, meta: { requiresAuth: true } }, - { path: '/study', component: { template: '
' }, meta: { requiresAuth: true } }, - { path: '/', redirect: '/login' }, - ], - }) - } - it('redirects unauthenticated user from protected route to /login', async () => { - const router = buildRouter() - router.beforeEach((to, _from, next) => { - const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true' - if (to.meta.requiresAuth && !isLoggedIn) { - next({ path: '/login' }) - } else { - next() - } - }) - await router.push('/welcome') - await router.isReady() + expect(router.currentRoute.value.path).toBe('/login') }) it('allows authenticated user to access protected route', async () => { localStorage.setItem('isLoggedIn', 'true') - const router = buildRouter() - router.beforeEach((to, _from, next) => { - const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true' - if (to.meta.requiresAuth && !isLoggedIn) { - next({ path: '/login' }) - } else { - next() - } - }) await router.push('/welcome') - await router.isReady() + expect(router.currentRoute.value.path).toBe('/welcome') }) it('allows unauthenticated access to /login', async () => { - const router = buildRouter() - router.beforeEach((to, _from, next) => { - const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true' - if (to.meta.requiresAuth && !isLoggedIn) { - next({ path: '/login' }) - } else { - next() - } - }) - await router.push('/login') - await router.isReady() + expect(router.currentRoute.value.path).toBe('/login') }) it('root path redirects to /login', async () => { - const router = buildRouter() - router.beforeEach((to, _from, next) => { - const isLoggedIn = localStorage.getItem('isLoggedIn') === 'true' - if (to.meta.requiresAuth && !isLoggedIn) { - next({ path: '/login' }) - } else { - next() - } - }) - await router.push('/') - await router.isReady() + expect(router.currentRoute.value.path).toBe('/login') }) }) diff --git a/src/__tests__/setup.ts b/src/__tests__/setup.ts index 7a2592a..6fabcd1 100644 --- a/src/__tests__/setup.ts +++ b/src/__tests__/setup.ts @@ -1,14 +1,29 @@ import { vi } from 'vitest' -// Mock Element Plus components globally -vi.mock('element-plus', () => ({ - ElMessage: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - }, - ElMessageBox: { - confirm: vi.fn().mockResolvedValue('confirm'), - }, -})) +// 只 mock Element Plus 的服务,组件保持真实实现,方便测试里挂载真实组件 +vi.mock('element-plus', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + ElMessage: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, + ElMessageBox: { + confirm: vi.fn().mockResolvedValue('confirm'), + alert: vi.fn().mockResolvedValue(undefined), + }, + } +}) + +// jsdom 没有 Audio,组件和 useTimer 需要用到 +class MockAudio { + loop = false + currentTime = 0 + play = vi.fn().mockResolvedValue(undefined) + pause = vi.fn() +} + +vi.stubGlobal('Audio', MockAudio) diff --git a/src/__tests__/utils/request.spec.ts b/src/__tests__/utils/request.spec.ts index c3ca43d..11e0139 100644 --- a/src/__tests__/utils/request.spec.ts +++ b/src/__tests__/utils/request.spec.ts @@ -22,15 +22,6 @@ vi.mock('axios', () => ({ }, })) -vi.mock('element-plus', () => ({ - ElMessage: { - success: vi.fn(), - error: vi.fn(), - warning: vi.fn(), - info: vi.fn(), - }, -})) - import request from '@/utils/request' describe('request utility', () => {