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