From 13846a0b6128b038b338711e65086f8877775842 Mon Sep 17 00:00:00 2001 From: cat_shark <1716967236@qq.com> Date: Wed, 27 May 2026 23:28:41 +0800 Subject: [PATCH] test: add vitest config and unit tests --- src/__tests__/api/login.spec.ts | 35 ++++ src/__tests__/api/reportFragments.spec.ts | 31 ++++ src/__tests__/api/review.spec.ts | 53 ++++++ src/__tests__/api/studySessions.spec.ts | 85 ++++++++++ src/__tests__/components/Login.spec.ts | 112 +++++++++++++ src/__tests__/components/MyHead.spec.ts | 106 ++++++++++++ src/__tests__/components/TaskForm.spec.ts | 180 +++++++++++++++++++++ src/__tests__/composables/fragment.spec.ts | 155 ++++++++++++++++++ src/__tests__/composables/useTimer.spec.ts | 88 ++++++++++ src/__tests__/router/guard.spec.ts | 90 +++++++++++ src/__tests__/setup.ts | 14 ++ src/__tests__/utils/request.spec.ts | 131 +++++++++++++++ vite.config.ts | 8 +- 13 files changed, 1087 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/api/login.spec.ts create mode 100644 src/__tests__/api/reportFragments.spec.ts create mode 100644 src/__tests__/api/review.spec.ts create mode 100644 src/__tests__/api/studySessions.spec.ts create mode 100644 src/__tests__/components/Login.spec.ts create mode 100644 src/__tests__/components/MyHead.spec.ts create mode 100644 src/__tests__/components/TaskForm.spec.ts create mode 100644 src/__tests__/composables/fragment.spec.ts create mode 100644 src/__tests__/composables/useTimer.spec.ts create mode 100644 src/__tests__/router/guard.spec.ts create mode 100644 src/__tests__/setup.ts create mode 100644 src/__tests__/utils/request.spec.ts diff --git a/src/__tests__/api/login.spec.ts b/src/__tests__/api/login.spec.ts new file mode 100644 index 0000000..668e657 --- /dev/null +++ b/src/__tests__/api/login.spec.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/utils/request', () => ({ + default: { + post: vi.fn(), + get: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +import request from '@/utils/request' +import { login, logout } from '@/api/login' + +const mockedRequest = vi.mocked(request) + +describe('login API', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('login sends POST to /login with credentials', async () => { + mockedRequest.post.mockResolvedValue({ code: 200, message: 'OK' }) + const result = await login('admin', '123456') + expect(mockedRequest.post).toHaveBeenCalledWith('/login', { username: 'admin', password: '123456' }) + expect(result).toEqual({ code: 200, message: 'OK' }) + }) + + it('logout sends POST to /logout', async () => { + mockedRequest.post.mockResolvedValue({ code: 200, message: '已退出' }) + const result = await logout() + expect(mockedRequest.post).toHaveBeenCalledWith('/logout') + expect(result).toEqual({ code: 200, message: '已退出' }) + }) +}) diff --git a/src/__tests__/api/reportFragments.spec.ts b/src/__tests__/api/reportFragments.spec.ts new file mode 100644 index 0000000..5a928c4 --- /dev/null +++ b/src/__tests__/api/reportFragments.spec.ts @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/utils/request', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +import request from '@/utils/request' +import { createFragments } from '@/api/reportFragments' + +const mockedRequest = vi.mocked(request) + +describe('reportFragments API', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('createFragments sends POST with sessionNum and content', async () => { + mockedRequest.post.mockResolvedValue({ code: 200, data: { id: 1 } }) + const result = await createFragments('S001', '学习了递归') + expect(mockedRequest.post).toHaveBeenCalledWith('/report-fragments', { + sessionNum: 'S001', + content: '学习了递归', + }) + expect(result).toEqual({ code: 200, data: { id: 1 } }) + }) +}) diff --git a/src/__tests__/api/review.spec.ts b/src/__tests__/api/review.spec.ts new file mode 100644 index 0000000..516f6a2 --- /dev/null +++ b/src/__tests__/api/review.spec.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/utils/request', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +import request from '@/utils/request' +import { getReviewFeed, getReportDetail, getFragmentDetail, getTaskReview } from '@/api/review' + +const mockedRequest = vi.mocked(request) + +describe('review API', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('getReviewFeed calls GET /review/feed with limit', async () => { + mockedRequest.get.mockResolvedValue({ code: 200, data: [] }) + const result = await getReviewFeed(10) + expect(mockedRequest.get).toHaveBeenCalledWith('/review/feed', { limit: 10 }) + expect(result).toEqual({ code: 200, data: [] }) + }) + + it('getReviewFeed uses default limit of 30', async () => { + mockedRequest.get.mockResolvedValue({ code: 200, data: [] }) + await getReviewFeed() + expect(mockedRequest.get).toHaveBeenCalledWith('/review/feed', { limit: 30 }) + }) + + it('getReportDetail calls GET /review/report/:id', async () => { + mockedRequest.get.mockResolvedValue({ code: 200, data: { id: 1 } }) + const result = await getReportDetail(1) + expect(mockedRequest.get).toHaveBeenCalledWith('/review/report/1') + expect(result).toEqual({ code: 200, data: { id: 1 } }) + }) + + it('getFragmentDetail calls GET /review/fragment/:id', async () => { + mockedRequest.get.mockResolvedValue({ code: 200, data: { id: 5 } }) + await getFragmentDetail(5) + expect(mockedRequest.get).toHaveBeenCalledWith('/review/fragment/5') + }) + + it('getTaskReview calls GET /review/task/:taskNum', async () => { + mockedRequest.get.mockResolvedValue({ code: 200, data: {} }) + await getTaskReview('T001') + expect(mockedRequest.get).toHaveBeenCalledWith('/review/task/T001') + }) +}) diff --git a/src/__tests__/api/studySessions.spec.ts b/src/__tests__/api/studySessions.spec.ts new file mode 100644 index 0000000..bef77d5 --- /dev/null +++ b/src/__tests__/api/studySessions.spec.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +vi.mock('@/utils/request', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + delete: vi.fn(), + }, +})) + +import request from '@/utils/request' +import { + continueSession, + pauseSession, + endSession, + startOrContinueStudySession, + getSessionDetail, +} from '@/api/studySessions' + +const mockedRequest = vi.mocked(request) + +describe('studySessions API', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('continueSession sends POST to correct endpoint', async () => { + mockedRequest.post.mockResolvedValue({ code: 200, data: null }) + await continueSession('S001') + expect(mockedRequest.post).toHaveBeenCalledWith('/study-sessions/S001/study-sessions/continue') + }) + + it('pauseSession sends POST with endTime param when provided', async () => { + mockedRequest.post.mockResolvedValue({ code: 200, data: null }) + const endTime = new Date('2024-01-15T10:30:00Z') + await pauseSession('S001', endTime) + expect(mockedRequest.post).toHaveBeenCalledWith( + '/study-sessions/S001/study-sessions/pause', + null, + { params: { endTime: endTime.toISOString() } } + ) + }) + + it('pauseSession sends POST with null params when endTime not provided', async () => { + mockedRequest.post.mockResolvedValue({ code: 200, data: null }) + await pauseSession('S001') + expect(mockedRequest.post).toHaveBeenCalledWith( + '/study-sessions/S001/study-sessions/pause', + null, + { params: null } + ) + }) + + it('endSession sends POST with default content', async () => { + mockedRequest.post.mockResolvedValue({ code: 200, data: null }) + await endSession('S001') + expect(mockedRequest.post).toHaveBeenCalledWith( + '/study-sessions/S001/study-sessions/ended', + { content: '任务结束' } + ) + }) + + it('endSession sends POST with custom content', async () => { + mockedRequest.post.mockResolvedValue({ code: 200, data: null }) + await endSession('S001', '自定义结束') + expect(mockedRequest.post).toHaveBeenCalledWith( + '/study-sessions/S001/study-sessions/ended', + { content: '自定义结束' } + ) + }) + + it('startOrContinueStudySession sends GET to correct endpoint', async () => { + mockedRequest.get.mockResolvedValue({ code: 200, data: { sessionNum: 'S002' } }) + const result = await startOrContinueStudySession('T001') + expect(mockedRequest.get).toHaveBeenCalledWith('/tasks/T001/study-sessions/start-or-continue') + expect(result).toEqual({ code: 200, data: { sessionNum: 'S002' } }) + }) + + it('getSessionDetail sends GET to correct endpoint', async () => { + mockedRequest.get.mockResolvedValue({ code: 200, data: { id: 1 } }) + await getSessionDetail('S001') + expect(mockedRequest.get).toHaveBeenCalledWith('/study-sessions/S001') + }) +}) diff --git a/src/__tests__/components/Login.spec.ts b/src/__tests__/components/Login.spec.ts new file mode 100644 index 0000000..2115001 --- /dev/null +++ b/src/__tests__/components/Login.spec.ts @@ -0,0 +1,112 @@ +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' + +// 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: '
' } }, + ], + }) +} + +describe('Login.vue logic', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + 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('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() + }) + }) + + 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() + + // Simulate submitForm logic + const username = 'admin' + const password = '123456' + const result = await login(username, password) + + 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') + }) + + it('shows error message on failed login', async () => { + vi.mocked(login).mockResolvedValue({ code: 401, message: '密码错误' } as any) + + const result = await login('admin', 'wrong') + if (result.code !== 200) { + ElMessage.error(result.message) + } + + expect(ElMessage.error).toHaveBeenCalledWith('密码错误') + expect(localStorage.getItem('isLoggedIn')).toBeNull() + }) + + 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() + }) + }) +}) diff --git a/src/__tests__/components/MyHead.spec.ts b/src/__tests__/components/MyHead.spec.ts new file mode 100644 index 0000000..8fc7e6b --- /dev/null +++ b/src/__tests__/components/MyHead.spec.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { computed, ref } from 'vue' + +vi.mock('@/api/login', () => ({ + login: vi.fn(), + logout: vi.fn().mockResolvedValue({ code: 200 }), +})) + +vi.mock('element-plus', () => ({ + ElMessage: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, +})) + +import { logout } from '@/api/login' +import { ElMessage } from 'element-plus' + +describe('MyHead.vue logic', () => { + beforeEach(() => { + vi.clearAllMocks() + localStorage.clear() + }) + + 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('isLoginRoute is false for other paths', () => { + const routePath = ref('/welcome') + const isLoginRoute = computed(() => routePath.value === '/login') + expect(isLoginRoute.value).toBe(false) + }) + + 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() + }) + }) + + describe('handleLogout logic', () => { + it('clears login state and navigates to login', async () => { + localStorage.setItem('isLoggedIn', 'true') + + // Simulate handleLogout + try { + await logout() + } catch { + // Backend failure shouldn't block logout + } finally { + localStorage.removeItem('isLoggedIn') + ElMessage.success('已退出登录') + } + + expect(localStorage.getItem('isLoggedIn')).toBeNull() + expect(ElMessage.success).toHaveBeenCalledWith('已退出登录') + }) + + it('clears login state even when API call fails', async () => { + vi.mocked(logout).mockRejectedValue(new Error('Network error')) + localStorage.setItem('isLoggedIn', 'true') + + try { + await logout() + } catch { + // Expected + } finally { + localStorage.removeItem('isLoggedIn') + ElMessage.success('已退出登录') + } + + expect(localStorage.getItem('isLoggedIn')).toBeNull() + expect(ElMessage.success).toHaveBeenCalledWith('已退出登录') + }) + }) +}) diff --git a/src/__tests__/components/TaskForm.spec.ts b/src/__tests__/components/TaskForm.spec.ts new file mode 100644 index 0000000..aa59f81 --- /dev/null +++ b/src/__tests__/components/TaskForm.spec.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref, reactive } from 'vue' + +vi.mock('element-plus', () => ({ + ElMessage: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + info: vi.fn(), + }, +})) + +import { ElMessage } from 'element-plus' + +describe('TaskForm.vue logic', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + 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) + }) + }) + + describe('priority state management', () => { + it('initializes with all zeros', () => { + const priority = reactive({ + urgency: 0, + importance: 0, + contentDifficulty: 0, + futureValue: 0, + subjectivePriority: 0, + }) + + 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) + }) + + 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) + }) + }) + + 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 = { + taskName: '学习DP', + taskDescription: '动态规划', + materialUrl: 'https://example.com/dp', + urgency: 4, + importance: 5, + 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') + }) + + it('falls back to empty string when no material URL', () => { + const apiData = {} as any + const result = apiData.materialURL || apiData.materialUrl || '' + expect(result).toBe('') + }) + }) + + describe('redirect after successful action', () => { + it('navigates to study page after create/update', () => { + const pushTarget = '/study' + expect(pushTarget).toBe('/study') + }) + }) +}) diff --git a/src/__tests__/composables/fragment.spec.ts b/src/__tests__/composables/fragment.spec.ts new file mode 100644 index 0000000..9b66244 --- /dev/null +++ b/src/__tests__/composables/fragment.spec.ts @@ -0,0 +1,155 @@ +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), + }, +})) + +vi.mock('@/api/reportFragments', () => ({ + createFragments: vi.fn(), +})) + +import { createFragments } from '@/api/reportFragments' + +describe('useStudyFragment composable logic', () => { + beforeEach(() => { + vi.clearAllMocks() + mockConfirm.mockResolvedValue('confirm') + }) + + it('openFragmentDialog resets content and shows dialog', () => { + const fragmentContent = ref('old content') + const fragmentsDialogVisible = ref(false) + + fragmentContent.value = '' + fragmentsDialogVisible.value = true + + expect(fragmentContent.value).toBe('') + expect(fragmentsDialogVisible.value).toBe(true) + }) + + it('closeFragmentDialog hides dialog on confirm', async () => { + const fragmentsDialogVisible = ref(true) + + await mockConfirm('确定取消生成吗?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning', + }).then(() => { + fragmentsDialogVisible.value = false + mockInfo('已取消生成') + }) + + expect(fragmentsDialogVisible.value).toBe(false) + expect(mockInfo).toHaveBeenCalledWith('已取消生成') + }) + + it('closeFragmentDialog keeps dialog open on cancel', async () => { + const fragmentsDialogVisible = ref(true) + mockConfirm.mockRejectedValue('cancel') + + try { + await mockConfirm('确定取消生成吗?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'warning', + }).then(() => { + fragmentsDialogVisible.value = false + }) + } catch { + // User cancelled + } + + expect(fragmentsDialogVisible.value).toBe(true) + }) + + it('confirmGenerateFragment warns if content is empty', async () => { + const fragmentContent = ref(' ') + + if (!fragmentContent.value.trim()) { + mockWarning('请输入学习内容!') + } + + expect(mockWarning).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) + + 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 + } + }) + } + + expect(createFragments).toHaveBeenCalledWith('S001', '学习了递归算法') + expect(mockSuccess).toHaveBeenCalledWith('学习残片生成成功!') + expect(fragmentsDialogVisible.value).toBe(false) + }) + + it('confirmGenerateFragment handles API failure', async () => { + vi.mocked(createFragments).mockResolvedValue({ code: 500, message: '生成失败' } as any) + const fragmentContent = ref('学习内容') + + 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 || '生成学习残片失败') + } + }) + } + + expect(mockError).toHaveBeenCalledWith('生成失败') + }) + + it('confirmGenerateFragment handles network error', async () => { + vi.mocked(createFragments).mockRejectedValue(new Error('网络超时')) + const fragmentContent = ref('学习内容') + + if (fragmentContent.value.trim()) { + await mockConfirm('确定要生成学习残片吗?', '提示', { + confirmButtonText: '确定', + cancelButtonText: '取消', + type: 'info', + }).then(async () => { + try { + await createFragments('S001', fragmentContent.value) + } catch (error: any) { + mockError(error.message || '请求失败') + } + }) + } + + expect(mockError).toHaveBeenCalledWith('网络超时') + }) +}) diff --git a/src/__tests__/composables/useTimer.spec.ts b/src/__tests__/composables/useTimer.spec.ts new file mode 100644 index 0000000..694dacc --- /dev/null +++ b/src/__tests__/composables/useTimer.spec.ts @@ -0,0 +1,88 @@ +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 + +describe('useTimer', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + 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(':') + } + + 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') + }) + + 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(':') + } + + expect(formatTime(86399)).toBe('23:59:59') + expect(formatTime(100000)).toBe('27:46:40') + }) + + it('timer increments elapsedSeconds over time', () => { + let elapsed = 0 + const interval = setInterval(() => { + elapsed++ + }, 1000) + + vi.advanceTimersByTime(3000) + expect(elapsed).toBe(3) + + 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() + }) +}) diff --git a/src/__tests__/router/guard.spec.ts b/src/__tests__/router/guard.spec.ts new file mode 100644 index 0000000..5852859 --- /dev/null +++ b/src/__tests__/router/guard.spec.ts @@ -0,0 +1,90 @@ +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. + +describe('route guards', () => { + beforeEach(() => { + localStorage.clear() + vi.restoreAllMocks() + }) + + 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 new file mode 100644 index 0000000..7a2592a --- /dev/null +++ b/src/__tests__/setup.ts @@ -0,0 +1,14 @@ +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'), + }, +})) diff --git a/src/__tests__/utils/request.spec.ts b/src/__tests__/utils/request.spec.ts new file mode 100644 index 0000000..c3ca43d --- /dev/null +++ b/src/__tests__/utils/request.spec.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const { mockGet, mockPost, mockPut, mockDel } = vi.hoisted(() => ({ + mockGet: vi.fn(), + mockPost: vi.fn(), + mockPut: vi.fn(), + mockDel: vi.fn(), +})) + +vi.mock('axios', () => ({ + default: { + create: vi.fn(() => ({ + get: mockGet, + post: mockPost, + put: mockPut, + delete: mockDel, + interceptors: { + request: { use: vi.fn() }, + response: { use: vi.fn() }, + }, + })), + }, +})) + +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', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('get', () => { + it('should call axios.get with url and params wrapped', async () => { + mockGet.mockResolvedValue({ data: { code: 200, data: 'test' } }) + const result = await request.get('/test', { id: 1 }) + expect(mockGet).toHaveBeenCalledWith('/test', { params: { id: 1 } }) + expect(result).toEqual({ code: 200, data: 'test' }) + }) + + it('should default to empty params object', async () => { + mockGet.mockResolvedValue({ data: { code: 200, data: 'test' } }) + await request.get('/test') + expect(mockGet).toHaveBeenCalledWith('/test', { params: {} }) + }) + + it('should reject when response code is not 200', async () => { + mockGet.mockResolvedValue({ data: { code: 400, message: 'Bad request' } }) + await expect(request.get('/test')).rejects.toEqual({ code: 400, message: 'Bad request' }) + }) + }) + + describe('post', () => { + it('should call axios.post with url, data, and empty config', async () => { + mockPost.mockResolvedValue({ data: { code: 200, data: 'created' } }) + const result = await request.post('/test', { name: 'foo' }) + expect(mockPost).toHaveBeenCalledWith('/test', { name: 'foo' }, {}) + expect(result).toEqual({ code: 200, data: 'created' }) + }) + + it('should pass config when provided', async () => { + mockPost.mockResolvedValue({ data: { code: 200, data: 'ok' } }) + await request.post('/test', null, { params: { id: 1 } }) + expect(mockPost).toHaveBeenCalledWith('/test', null, { params: { id: 1 } }) + }) + + it('should reject when response code is not 200', async () => { + mockPost.mockResolvedValue({ data: { code: 500, message: 'Server error' } }) + await expect(request.post('/test')).rejects.toEqual({ code: 500, message: 'Server error' }) + }) + }) + + describe('put', () => { + it('should call axios.put with url, data, and empty config', async () => { + mockPut.mockResolvedValue({ data: { code: 200, data: 'updated' } }) + const result = await request.put('/test/1', { name: 'bar' }) + expect(mockPut).toHaveBeenCalledWith('/test/1', { name: 'bar' }, {}) + expect(result).toEqual({ code: 200, data: 'updated' }) + }) + }) + + describe('del', () => { + it('should call axios.delete with url and params wrapped', async () => { + mockDel.mockResolvedValue({ data: { code: 200, data: 'deleted' } }) + const result = await request.del('/test/1') + expect(mockDel).toHaveBeenCalledWith('/test/1', { params: {} }) + expect(result).toEqual({ code: 200, data: 'deleted' }) + }) + + it('should merge params into config spread for axios.delete', async () => { + mockDel.mockResolvedValue({ data: { code: 200, data: 'ok' } }) + // del(url, params={reason:'test'}, config={timeout:1000}) + // => axios.delete(url, { params: { reason:'test' }, timeout: 1000 }) + await request.del('/test/1', { reason: 'test' }, { timeout: 1000 }) + expect(mockDel).toHaveBeenCalledWith('/test/1', { params: { reason: 'test' }, timeout: 1000 }) + }) + }) + + describe('generic request', () => { + it('should dispatch GET with params', async () => { + mockGet.mockResolvedValue({ data: { code: 200, data: 'ok' } }) + const result = await request.request('get', '/test', { id: 1 }) + expect(mockGet).toHaveBeenCalledWith('/test', { params: { id: 1 } }) + expect(result).toEqual({ code: 200, data: 'ok' }) + }) + + it('should dispatch POST with data', async () => { + mockPost.mockResolvedValue({ data: { code: 200, data: 'ok' } }) + await request.request('post', '/test', { name: 'foo' }) + expect(mockPost).toHaveBeenCalledWith('/test', { name: 'foo' }, {}) + }) + + it('should dispatch DELETE with params', async () => { + mockDel.mockResolvedValue({ data: { code: 200, data: 'ok' } }) + await request.request('delete', '/test/1', null) + expect(mockDel).toHaveBeenCalledWith('/test/1', { params: null }) + }) + + it('should throw for unsupported method', () => { + expect(() => request.request('patch', '/test', null)).toThrow('请求方法 patch 未实现') + }) + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 68aece1..509ade1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -23,5 +23,11 @@ export default defineConfig({ rewrite: (path) => path.replace(/^\/api/, ""), }, }, - } + }, + test: { + globals: true, + environment: 'jsdom', + setupFiles: ['./src/__tests__/setup.ts'], + include: ['src/**/*.spec.ts', 'src/**/*.test.ts'], + }, })