feat: 初始化考培练系统项目
- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
This commit is contained in:
215
frontend/src/test/setup.ts
Normal file
215
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 测试环境设置
|
||||
*/
|
||||
|
||||
import { vi, afterEach } from 'vitest'
|
||||
import { config } from '@vue/test-utils'
|
||||
|
||||
// 全局测试配置
|
||||
config.global.stubs = {
|
||||
// Element Plus 组件存根
|
||||
'el-button': true,
|
||||
'el-input': true,
|
||||
'el-form': true,
|
||||
'el-form-item': true,
|
||||
'el-message': true,
|
||||
'el-message-box': true,
|
||||
'el-notification': true,
|
||||
'el-loading': true,
|
||||
'el-table': true,
|
||||
'el-table-column': true,
|
||||
'el-pagination': true,
|
||||
'el-dialog': true,
|
||||
'el-drawer': true,
|
||||
'el-card': true,
|
||||
'el-tag': true,
|
||||
'el-badge': true,
|
||||
'el-avatar': true,
|
||||
'el-tooltip': true,
|
||||
'el-popover': true,
|
||||
'el-dropdown': true,
|
||||
'el-dropdown-menu': true,
|
||||
'el-dropdown-item': true,
|
||||
'el-menu': true,
|
||||
'el-menu-item': true,
|
||||
'el-submenu': true,
|
||||
'el-breadcrumb': true,
|
||||
'el-breadcrumb-item': true,
|
||||
'el-tabs': true,
|
||||
'el-tab-pane': true,
|
||||
'el-collapse': true,
|
||||
'el-collapse-item': true,
|
||||
'el-tree': true,
|
||||
'el-select': true,
|
||||
'el-option': true,
|
||||
'el-checkbox': true,
|
||||
'el-checkbox-group': true,
|
||||
'el-radio': true,
|
||||
'el-radio-group': true,
|
||||
'el-switch': true,
|
||||
'el-slider': true,
|
||||
'el-rate': true,
|
||||
'el-color-picker': true,
|
||||
'el-transfer': true,
|
||||
'el-upload': true,
|
||||
'el-progress': true,
|
||||
'el-skeleton': true,
|
||||
'el-empty': true,
|
||||
'el-result': true,
|
||||
'el-alert': true,
|
||||
'el-descriptions': true,
|
||||
'el-descriptions-item': true,
|
||||
'el-timeline': true,
|
||||
'el-timeline-item': true,
|
||||
'el-divider': true,
|
||||
'el-backtop': true,
|
||||
'el-calendar': true,
|
||||
'el-image': true,
|
||||
'el-carousel': true,
|
||||
'el-carousel-item': true,
|
||||
'el-steps': true,
|
||||
'el-step': true,
|
||||
'el-row': true,
|
||||
'el-col': true,
|
||||
'el-container': true,
|
||||
'el-header': true,
|
||||
'el-aside': true,
|
||||
'el-main': true,
|
||||
'el-footer': true,
|
||||
'el-space': true,
|
||||
'el-affix': true,
|
||||
'el-anchor': true,
|
||||
'el-anchor-link': true,
|
||||
|
||||
// Element Plus Icons
|
||||
'el-icon': true,
|
||||
|
||||
// Router
|
||||
'router-link': true,
|
||||
'router-view': true
|
||||
}
|
||||
|
||||
// 全局 mocks
|
||||
config.global.mocks = {
|
||||
$t: (msg: string) => msg, // 国际化mock
|
||||
$route: {
|
||||
path: '/',
|
||||
query: {},
|
||||
params: {},
|
||||
meta: {}
|
||||
},
|
||||
$router: {
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
go: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
// Mock localStorage
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: {
|
||||
store: {} as Record<string, string>,
|
||||
getItem(key: string) {
|
||||
return this.store[key] || null
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
this.store[key] = String(value)
|
||||
},
|
||||
removeItem(key: string) {
|
||||
delete this.store[key]
|
||||
},
|
||||
clear() {
|
||||
this.store = {}
|
||||
}
|
||||
},
|
||||
writable: true
|
||||
})
|
||||
|
||||
// Mock sessionStorage
|
||||
Object.defineProperty(window, 'sessionStorage', {
|
||||
value: {
|
||||
store: {} as Record<string, string>,
|
||||
getItem(key: string) {
|
||||
return this.store[key] || null
|
||||
},
|
||||
setItem(key: string, value: string) {
|
||||
this.store[key] = String(value)
|
||||
},
|
||||
removeItem(key: string) {
|
||||
delete this.store[key]
|
||||
},
|
||||
clear() {
|
||||
this.store = {}
|
||||
}
|
||||
},
|
||||
writable: true
|
||||
})
|
||||
|
||||
// Mock window.location
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
href: 'http://localhost:3000/',
|
||||
origin: 'http://localhost:3000',
|
||||
protocol: 'http:',
|
||||
host: 'localhost:3000',
|
||||
hostname: 'localhost',
|
||||
port: '3000',
|
||||
pathname: '/',
|
||||
search: '',
|
||||
hash: '',
|
||||
reload: vi.fn(),
|
||||
assign: vi.fn(),
|
||||
replace: vi.fn()
|
||||
},
|
||||
writable: true
|
||||
})
|
||||
|
||||
// Mock window.matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation(query => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn()
|
||||
}))
|
||||
})
|
||||
|
||||
// Mock ResizeObserver
|
||||
global.ResizeObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock IntersectionObserver
|
||||
global.IntersectionObserver = vi.fn().mockImplementation(() => ({
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
// Mock console methods for cleaner test output
|
||||
global.console = {
|
||||
...console,
|
||||
// 在测试中静默某些日志
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn()
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
localStorage.clear()
|
||||
sessionStorage.clear()
|
||||
})
|
||||
309
frontend/src/test/utils.ts
Normal file
309
frontend/src/test/utils.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 测试工具函数
|
||||
*/
|
||||
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { Component } from 'vue'
|
||||
import { Router, createRouter, createWebHistory } from 'vue-router'
|
||||
import { createPinia, Pinia } from 'pinia'
|
||||
import { expect } from 'vitest'
|
||||
|
||||
// 测试路由配置
|
||||
export const createTestRouter = (routes: any[] = []): Router => {
|
||||
return createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'Home', component: { template: '<div>Home</div>' } },
|
||||
{ path: '/login', name: 'Login', component: { template: '<div>Login</div>' } },
|
||||
{ path: '/404', name: 'NotFound', component: { template: '<div>404</div>' } },
|
||||
...routes
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
// 创建测试用的 Pinia 实例
|
||||
export const createTestPinia = (): Pinia => {
|
||||
return createPinia()
|
||||
}
|
||||
|
||||
// 挂载组件的增强函数
|
||||
export interface MountOptions {
|
||||
props?: Record<string, any>
|
||||
slots?: Record<string, any>
|
||||
router?: Router
|
||||
pinia?: Pinia
|
||||
global?: {
|
||||
plugins?: any[]
|
||||
mocks?: Record<string, any>
|
||||
stubs?: Record<string, any>
|
||||
provide?: Record<string, any>
|
||||
}
|
||||
}
|
||||
|
||||
export const mountComponent = (
|
||||
component: Component,
|
||||
options: MountOptions = {}
|
||||
): VueWrapper => {
|
||||
const { props, slots, router, pinia, global } = options
|
||||
|
||||
const plugins = []
|
||||
const provide: Record<string, any> = {}
|
||||
|
||||
if (router) {
|
||||
plugins.push(router)
|
||||
}
|
||||
|
||||
if (pinia) {
|
||||
plugins.push(pinia)
|
||||
}
|
||||
|
||||
return mount(component, {
|
||||
props,
|
||||
slots,
|
||||
global: {
|
||||
plugins,
|
||||
provide: {
|
||||
...provide,
|
||||
...global?.provide
|
||||
},
|
||||
mocks: {
|
||||
...global?.mocks
|
||||
},
|
||||
stubs: {
|
||||
...global?.stubs
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 等待 DOM 更新
|
||||
export const waitForUpdate = async (wrapper: VueWrapper): Promise<void> => {
|
||||
await wrapper.vm.$nextTick()
|
||||
}
|
||||
|
||||
// 模拟用户交互
|
||||
export const userInteraction = {
|
||||
// 点击元素
|
||||
async click(wrapper: VueWrapper, selector: string): Promise<void> {
|
||||
const element = wrapper.find(selector)
|
||||
await element.trigger('click')
|
||||
await waitForUpdate(wrapper)
|
||||
},
|
||||
|
||||
// 输入文本
|
||||
async type(wrapper: VueWrapper, selector: string, text: string): Promise<void> {
|
||||
const input = wrapper.find(selector)
|
||||
await input.setValue(text)
|
||||
await waitForUpdate(wrapper)
|
||||
},
|
||||
|
||||
// 提交表单
|
||||
async submit(wrapper: VueWrapper, selector: string = 'form'): Promise<void> {
|
||||
const form = wrapper.find(selector)
|
||||
await form.trigger('submit')
|
||||
await waitForUpdate(wrapper)
|
||||
},
|
||||
|
||||
// 鼠标悬停
|
||||
async hover(wrapper: VueWrapper, selector: string): Promise<void> {
|
||||
const element = wrapper.find(selector)
|
||||
await element.trigger('mouseenter')
|
||||
await waitForUpdate(wrapper)
|
||||
},
|
||||
|
||||
// 键盘事件
|
||||
async keydown(wrapper: VueWrapper, selector: string, key: string): Promise<void> {
|
||||
const element = wrapper.find(selector)
|
||||
await element.trigger('keydown', { key })
|
||||
await waitForUpdate(wrapper)
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟 API 响应
|
||||
export const mockApiResponse = {
|
||||
success: <T>(data: T) => ({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data
|
||||
}),
|
||||
|
||||
error: (message: string, code: number = 400) => ({
|
||||
code,
|
||||
message,
|
||||
data: null
|
||||
}),
|
||||
|
||||
paginated: <T>(items: T[], total?: number, page: number = 1, size: number = 10) => ({
|
||||
code: 200,
|
||||
message: 'success',
|
||||
data: {
|
||||
items,
|
||||
total: total ?? items.length,
|
||||
page,
|
||||
size
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建模拟用户数据
|
||||
export const createMockUser = (overrides: Partial<any> = {}) => ({
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
full_name: '测试用户',
|
||||
role: 'trainee',
|
||||
is_active: true,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
updated_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// 创建模拟课程数据
|
||||
export const createMockCourse = (overrides: Partial<any> = {}) => ({
|
||||
id: 1,
|
||||
title: '测试课程',
|
||||
description: '这是一个测试课程',
|
||||
coverImage: '',
|
||||
category: '技术',
|
||||
difficulty: 'beginner',
|
||||
duration: 60,
|
||||
materialCount: 5,
|
||||
progress: 0,
|
||||
rating: 4.5,
|
||||
status: 'published',
|
||||
tags: ['测试', '课程'],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// 创建模拟考试数据
|
||||
export const createMockExam = (overrides: Partial<any> = {}) => ({
|
||||
id: '1',
|
||||
name: '测试考试',
|
||||
type: 'practice',
|
||||
subject: '测试科目',
|
||||
totalScore: 100,
|
||||
userScore: 85,
|
||||
accuracy: 0.85,
|
||||
duration: 3600,
|
||||
status: 'completed',
|
||||
startTime: '2024-01-01T10:00:00.000Z',
|
||||
endTime: '2024-01-01T11:00:00.000Z',
|
||||
createdAt: '2024-01-01T10:00:00.000Z',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// 创建模拟陪练记录数据
|
||||
export const createMockPracticeRecord = (overrides: Partial<any> = {}) => ({
|
||||
id: '1',
|
||||
sceneId: 1,
|
||||
sceneName: '测试陪练场景',
|
||||
sceneCategory: '技术面试',
|
||||
duration: 1800,
|
||||
messageCount: 20,
|
||||
overallScore: 85,
|
||||
result: 'good',
|
||||
startTime: '2024-01-01T14:00:00.000Z',
|
||||
endTime: '2024-01-01T14:30:00.000Z',
|
||||
createdAt: '2024-01-01T14:30:00.000Z',
|
||||
...overrides
|
||||
})
|
||||
|
||||
// 断言辅助函数
|
||||
export const assertions = {
|
||||
// 检查元素是否存在
|
||||
elementExists(wrapper: VueWrapper, selector: string): void {
|
||||
expect(wrapper.find(selector).exists()).toBe(true)
|
||||
},
|
||||
|
||||
// 检查元素是否不存在
|
||||
elementNotExists(wrapper: VueWrapper, selector: string): void {
|
||||
expect(wrapper.find(selector).exists()).toBe(false)
|
||||
},
|
||||
|
||||
// 检查元素文本
|
||||
elementText(wrapper: VueWrapper, selector: string, expectedText: string): void {
|
||||
expect(wrapper.find(selector).text()).toBe(expectedText)
|
||||
},
|
||||
|
||||
// 检查元素是否包含文本
|
||||
elementContainsText(wrapper: VueWrapper, selector: string, expectedText: string): void {
|
||||
expect(wrapper.find(selector).text()).toContain(expectedText)
|
||||
},
|
||||
|
||||
// 检查元素是否可见
|
||||
elementVisible(wrapper: VueWrapper, selector: string): void {
|
||||
const element = wrapper.find(selector)
|
||||
expect(element.exists()).toBe(true)
|
||||
expect(element.isVisible()).toBe(true)
|
||||
},
|
||||
|
||||
// 检查元素是否隐藏
|
||||
elementHidden(wrapper: VueWrapper, selector: string): void {
|
||||
const element = wrapper.find(selector)
|
||||
if (element.exists()) {
|
||||
expect(element.isVisible()).toBe(false)
|
||||
}
|
||||
},
|
||||
|
||||
// 检查表单字段值
|
||||
inputValue(wrapper: VueWrapper, selector: string, expectedValue: string): void {
|
||||
const input = wrapper.find(selector)
|
||||
expect((input.element as HTMLInputElement).value).toBe(expectedValue)
|
||||
},
|
||||
|
||||
// 检查路由导航
|
||||
routePushed(mockRouter: any, expectedRoute: string | object): void {
|
||||
expect(mockRouter.push).toHaveBeenCalledWith(expectedRoute)
|
||||
}
|
||||
}
|
||||
|
||||
// 时间相关工具
|
||||
export const timeUtils = {
|
||||
// 模拟延时
|
||||
delay: (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms)),
|
||||
|
||||
// 获取当前时间戳
|
||||
now: (): number => Date.now(),
|
||||
|
||||
// 格式化时间
|
||||
format: (date: Date | string | number): string => {
|
||||
const d = new Date(date)
|
||||
return d.toISOString()
|
||||
}
|
||||
}
|
||||
|
||||
// 随机数据生成器
|
||||
export const faker = {
|
||||
// 随机字符串
|
||||
string: (length: number = 10): string => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
},
|
||||
|
||||
// 随机数字
|
||||
number: (min: number = 0, max: number = 100): number => {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
},
|
||||
|
||||
// 随机邮箱
|
||||
email: (): string => {
|
||||
return `${faker.string(8)}@example.com`
|
||||
},
|
||||
|
||||
// 随机中文姓名
|
||||
chineseName: (): string => {
|
||||
const surnames = ['王', '李', '张', '刘', '陈', '杨', '赵', '黄', '周', '吴']
|
||||
const names = ['伟', '芳', '娜', '敏', '静', '丽', '强', '磊', '军', '洋']
|
||||
return surnames[Math.floor(Math.random() * surnames.length)] +
|
||||
names[Math.floor(Math.random() * names.length)]
|
||||
},
|
||||
|
||||
// 随机布尔值
|
||||
boolean: (): boolean => Math.random() < 0.5
|
||||
}
|
||||
Reference in New Issue
Block a user