前端自动化测试 #54

Merged
hnu202326010204 merged 1 commits from hufan_branch into develop 5 days ago

@ -1,5 +1,273 @@
# Vue 3 + Vite
# MuseGuard 前端
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
MuseGuard 是一个基于 Vue 3 + Vite 的图像保护平台前端应用,提供图像加噪、微调、热力图分析和效果评估等功能。
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).
## 🛠️ 技术栈
- **框架**: Vue 3 (Composition API + `<script setup>`)
- **构建工具**: Vite 7
- **状态管理**: Pinia
- **路由**: Vue Router 4
- **HTTP 客户端**: Axios
- **3D 渲染**: Three.js
- **测试框架**: Vitest + Vue Test Utils + fast-check
## 📁 项目结构
```
frontend/
├── public/ # 静态资源
│ ├── method_examples/ # 方法示例图片
│ ├── papers/ # 论文相关图片
│ └── principle/ # 原理说明图片
├── src/
│ ├── api/ # API 接口模块
│ │ ├── index.js # API 导出入口
│ │ ├── auth.js # 认证相关 API
│ │ ├── user.js # 用户相关 API
│ │ ├── admin.js # 管理员相关 API
│ │ ├── task.js # 任务相关 API
│ │ ├── image.js # 图片相关 API
│ │ └── demo.js # 演示相关 API
│ ├── components/ # 通用组件
│ │ ├── Button.vue # 按钮组件
│ │ ├── NavBar.vue # 导航栏组件
│ │ ├── KtModal.vue # 模态框组件
│ │ ├── Toast.vue # 提示组件
│ │ ├── ImagePreviewModal.vue # 图片预览模态框
│ │ └── ... # 其他组件
│ ├── views/ # 页面视图
│ │ ├── LoginView.vue # 登录页面
│ │ ├── MainFlow.vue # 主流程页面
│ │ ├── home/ # 首页
│ │ ├── Page1-5/ # 功能页面
│ │ └── ...
│ ├── stores/ # Pinia 状态管理
│ │ ├── userStore.js # 用户状态
│ │ └── taskStore.js # 任务状态
│ ├── utils/ # 工具函数
│ │ ├── request.js # Axios 封装
│ │ ├── theme.js # 主题切换
│ │ ├── toast.js # Toast 工具
│ │ ├── multipartParser.js # Multipart 解析器
│ │ └── ...
│ ├── router/ # 路由配置
│ │ └── index.js
│ ├── App.vue # 根组件
│ ├── main.js # 入口文件
│ └── Style.css # 全局样式
├── test/ # 测试目录
│ ├── unit/ # 单元测试
│ ├── integration/ # 集成测试
│ ├── __mocks__/ # Mock 模块
│ ├── setup.js # 测试配置
│ └── factories.js # 测试数据工厂
├── .kiro/ # Kiro 配置
│ └── specs/ # 功能规格文档
├── vite.config.js # Vite 配置
├── vitest.config.js # Vitest 测试配置
└── package.json # 项目配置
```
## 🚀 快速开始
### 环境要求
- Node.js >= 18
- npm >= 9
### 安装依赖
```bash
npm install
```
### 启动开发服务器
```bash
npm run dev
```
应用将在 http://localhost:5173 启动。
### 构建生产版本
```bash
npm run build
```
### 预览生产版本
```bash
npm run preview
```
## 🔗 前后端连接配置
### API 代理配置
前端通过 Vite 的代理功能连接后端 API。配置位于 `vite.config.js`
```javascript
export default defineConfig({
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://127.0.0.1:6001', // 后端服务地址
changeOrigin: true,
}
}
}
})
```
### 后端服务要求
- **默认地址**: `http://127.0.0.1:6001`
- **API 前缀**: `/api`
- **认证方式**: JWT Bearer Token
### 修改后端地址
如果后端服务运行在不同的地址或端口,修改 `vite.config.js` 中的 `target`
```javascript
proxy: {
'/api': {
target: 'http://your-backend-host:port',
changeOrigin: true,
}
}
```
### API 请求封装
所有 API 请求通过 `src/utils/request.js` 统一处理:
```javascript
import axios from 'axios'
const service = axios.create({
baseURL: '/api', // API 基础路径
timeout: 30000, // 请求超时时间
headers: {
'Content-Type': 'application/json;charset=utf-8'
}
})
```
**功能特性**:
- 自动添加 JWT Token 到请求头
- 请求/响应拦截器
- 自动重试机制
- 统一错误处理
- Token 过期自动跳转登录
### 主要 API 端点
| 模块 | 端点 | 说明 |
|------|------|------|
| 认证 | `POST /api/auth/login` | 用户登录 |
| 认证 | `POST /api/auth/register` | 用户注册 |
| 用户 | `GET /api/user/profile` | 获取用户信息 |
| 任务 | `POST /api/task/create` | 创建任务 |
| 任务 | `GET /api/task/list` | 获取任务列表 |
| 任务 | `GET /api/task/{id}` | 获取任务详情 |
| 图片 | `POST /api/image/upload` | 上传图片 |
| 图片 | `GET /api/image/preview/{id}` | 预览图片 |
### 环境变量配置
可以通过环境变量配置不同环境的 API 地址:
```bash
# .env.development
VITE_API_BASE_URL=http://127.0.0.1:6001
# .env.production
VITE_API_BASE_URL=https://api.museguard.com
```
然后在 `vite.config.js` 中使用:
```javascript
proxy: {
'/api': {
target: process.env.VITE_API_BASE_URL || 'http://127.0.0.1:6001',
changeOrigin: true,
}
}
```
## 🧪 测试
### 运行测试
```bash
# 运行所有测试
npm run test
# 监听模式
npm run test:watch
# 生成覆盖率报告
npx vitest run --coverage
```
### 测试统计
- **测试文件**: 18 个
- **测试用例**: 280+ 个
- **覆盖类型**: 单元测试、集成测试、属性测试
详细信息请查看 [test/README.md](./test/README.md)。
## 📦 依赖说明
### 生产依赖
| 包名 | 版本 | 说明 |
|------|------|------|
| vue | ^3.5.24 | Vue 3 框架 |
| vue-router | ^4.6.3 | Vue 路由 |
| pinia | ^2.1.0 | 状态管理 |
| axios | ^1.13.2 | HTTP 客户端 |
| three | ^0.182.0 | 3D 渲染库 |
| jszip | ^3.10.1 | ZIP 文件处理 |
### 开发依赖
| 包名 | 版本 | 说明 |
|------|------|------|
| vite | ^7.2.4 | 构建工具 |
| @vitejs/plugin-vue | ^6.0.1 | Vue 插件 |
| vitest | ^1.0.0 | 测试框架 |
| @vue/test-utils | ^2.4.6 | Vue 测试工具 |
| jsdom | ^24.0.0 | DOM 模拟 |
| fast-check | ^3.15.0 | 属性测试库 |
## 🔧 开发指南
### 代码规范
- 使用 Vue 3 Composition API
- 组件使用 `<script setup>` 语法
- 状态管理使用 Pinia
- API 调用统一通过 `src/api/` 模块
### 添加新页面
1. 在 `src/views/` 创建页面组件
2. 在 `src/router/index.js` 添加路由
3. 如需状态管理,在 `src/stores/` 创建 store
### 添加新 API
1. 在 `src/api/` 创建或修改对应模块
2. 在 `src/api/index.js` 导出
3. 在组件中导入使用
## 📄 许可证
MIT License

@ -0,0 +1,193 @@
# MuseGuard 前端测试套件
本目录包含 MuseGuard 前端的完整测试框架,包括单元测试、集成测试和基于属性的测试 (Property-Based Testing)。
## 📁 目录结构
```
test/
├── __init__.js # 测试包初始化
├── setup.js # 全局测试环境配置 (Mock、Pinia、WebGL等)
├── factories.js # 测试数据工厂 (User、Task、Image、Config)
├── README.md # 本文档
├── __mocks__/ # Mock 模块
│ └── three.js # Three.js Mock (避免 WebGL 错误)
├── unit/ # 单元测试
│ ├── __init__.js
│ ├── utils/ # 工具函数测试
│ │ ├── theme.test.js # 主题切换功能
│ │ ├── navbarHighlight.test.js # 导航高亮计算
│ │ └── multipartParser.test.js # Multipart 响应解析
│ ├── stores/ # Pinia 状态管理测试
│ │ ├── userStore.test.js # 用户状态管理
│ │ └── taskStore.test.js # 任务状态管理
│ ├── api/ # API 模块测试
│ │ ├── auth.test.js # 认证 API
│ │ └── task.test.js # 任务 API
│ └── properties/ # 属性测试 (Property-Based Testing)
│ ├── theme.property.test.js
│ ├── navbarHighlight.property.test.js
│ └── stores.property.test.js
└── integration/ # 集成测试
├── __init__.js
├── components/ # 组件测试
│ ├── Button.test.js
│ ├── NavBar.test.js
│ ├── KtModal.test.js
│ ├── Toast.test.js
│ └── ImagePreviewModal.test.js
└── views/ # 视图测试
├── LoginView.test.js
└── MainFlow.test.js
```
## 🚀 快速开始
### 安装依赖
```bash
npm install
```
### 运行测试
```bash
# 运行所有测试
npm run test
# 监听模式
npm run test:watch
# 运行特定目录
npx vitest run test/unit/
npx vitest run test/integration/
# 运行特定文件
npx vitest run test/unit/utils/theme.test.js
# 生成覆盖率报告
npx vitest run --coverage
```
## 📊 测试统计
| 类型 | 文件数 | 测试用例数 |
|------|--------|-----------|
| 单元测试 | 8 | ~130 |
| 集成测试 | 7 | ~70 |
| 属性测试 | 3 | ~30 |
| **总计** | **17** | **~265** |
## 🧪 测试类型说明
### 单元测试 (Unit Tests)
测试独立的函数和模块,不依赖外部服务。
- `utils/`: 工具函数的基本功能
- `stores/`: Pinia 状态管理逻辑
- `api/`: API 模块(使用 Mock
### 集成测试 (Integration Tests)
测试组件和视图的交互行为。
- `components/`: Vue 组件的渲染和交互
- `views/`: 页面视图的完整功能
### 属性测试 (Property-Based Tests)
使用 fast-check 库自动生成测试数据,验证代码的通用属性。
- 主题切换的幂等性
- 导航高亮计算的边界条件
- Store 状态的一致性
## 🛠️ 测试工具
### setup.js 提供的全局 Mock
| 工具 | 说明 |
|------|------|
| `mockLocalStorage()` | 模拟 localStorage |
| `mockSessionStorage()` | 模拟 sessionStorage |
| `createMockRouter()` | 创建模拟路由 |
| `createMockAxios()` | 创建模拟 axios |
| `mockWebGL()` | 模拟 WebGL Context |
| `MockResizeObserver` | 模拟 ResizeObserver |
| `MockIntersectionObserver` | 模拟 IntersectionObserver |
### factories.js 提供的数据工厂
| 工厂 | 说明 |
|------|------|
| `UserFactory` | 创建用户数据 (普通用户、管理员、VIP) |
| `TaskFactory` | 创建任务数据 (加噪、微调、热力图、评估) |
| `ImageFactory` | 创建图片数据 (原图、加噪图、生成图) |
| `ConfigFactory` | 创建配置数据 (加噪配置、微调配置) |
## 📝 编写测试示例
### 单元测试
```javascript
import { describe, it, expect } from 'vitest'
import { someFunction } from '@/utils/example'
describe('Example 工具函数', () => {
it('应该正确处理输入', () => {
const result = someFunction('test')
expect(result).toBe('expected')
})
})
```
### 组件测试
```javascript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import MyComponent from '@/components/MyComponent.vue'
describe('MyComponent', () => {
it('应该正确渲染', () => {
const wrapper = mount(MyComponent, {
props: { title: '测试' }
})
expect(wrapper.text()).toContain('测试')
})
})
```
### 属性测试
```javascript
import { describe, it } from 'vitest'
import * as fc from 'fast-check'
import { someFunction } from '@/utils/example'
describe('属性测试', () => {
/**
* Property 1: 输出始终为正数
* Validates: Requirements X.Y
*/
it('输出始终为正数', () => {
fc.assert(
fc.property(fc.integer({ min: 0, max: 100 }), (input) => {
return someFunction(input) >= 0
}),
{ numRuns: 100 }
)
})
})
```
## ⚠️ 注意事项
1. **Three.js Mock**: 项目使用 Three.js 进行 3D 渲染,测试环境通过 `test/__mocks__/three.js` 进行 Mock避免 WebGL 相关错误。
2. **WebGL Context**: `setup.js` 中 Mock 了 `HTMLCanvasElement.getContext`,确保测试环境不依赖真实的 WebGL。
3. **浮点数精度**: `navbarHighlight.js` 中的 `isHighlightWithinBounds` 函数使用 epsilon 容差处理浮点数比较。
4. **Teleport 组件**: 测试使用 Teleport 的组件时,需要添加 `teleport: true` stub。
## 📚 相关文档
- [Vitest 文档](https://vitest.dev/)
- [Vue Test Utils 文档](https://test-utils.vuejs.org/)
- [fast-check 文档](https://fast-check.dev/)

@ -0,0 +1,4 @@
/**
* MuseGuard 前端测试包
* 包含单元测试集成测试和基于属性的测试
*/

@ -0,0 +1,153 @@
/**
* Three.js Mock
* 用于测试环境中模拟 Three.js
*/
// Mock Scene
export class Scene {
constructor() {
this.children = []
}
add(obj) { this.children.push(obj) }
remove(obj) { this.children = this.children.filter(c => c !== obj) }
}
// Mock Camera
export class OrthographicCamera {
constructor() {
this.position = { x: 0, y: 0, z: 0 }
this.left = 0
this.right = 0
this.top = 0
this.bottom = 0
}
updateProjectionMatrix() {}
}
export class PerspectiveCamera {
constructor() {
this.position = { x: 0, y: 0, z: 0 }
this.fov = 75
this.aspect = 1
this.near = 0.1
this.far = 1000
}
updateProjectionMatrix() {}
}
// Mock Renderer
export class WebGLRenderer {
constructor() {
this.domElement = document.createElement('canvas')
}
setPixelRatio() {}
setSize() {}
render() {}
dispose() {}
}
// Mock Geometry
export class PlaneGeometry {
constructor() {}
dispose() {}
}
export class BoxGeometry {
constructor() {}
dispose() {}
}
// Mock Material
export class ShaderMaterial {
constructor(params = {}) {
this.uniforms = params.uniforms || {}
}
dispose() {}
}
export class MeshBasicMaterial {
constructor() {}
dispose() {}
}
// Mock Mesh
export class Mesh {
constructor(geometry, material) {
this.geometry = geometry
this.material = material
this.scale = { x: 1, y: 1, z: 1, set: () => {} }
this.position = { x: 0, y: 0, z: 0 }
this.rotation = { x: 0, y: 0, z: 0 }
}
}
// Mock Texture
export class Texture {
constructor() {
this.image = { width: 100, height: 100 }
this.needsUpdate = false
}
dispose() {}
}
export class DataTexture {
constructor(data, width, height) {
this.image = { data, width, height }
this.needsUpdate = false
}
dispose() {}
}
// Mock TextureLoader
export class TextureLoader {
load(url, onLoad) {
const texture = new Texture()
texture.image = { width: 800, height: 600 }
if (onLoad) {
setTimeout(() => onLoad(texture), 0)
}
return texture
}
}
// Mock Constants
export const RGBAFormat = 1023
export const FloatType = 1015
export const LinearFilter = 1006
// Mock Vector classes
export class Vector2 {
constructor(x = 0, y = 0) {
this.x = x
this.y = y
}
}
export class Vector3 {
constructor(x = 0, y = 0, z = 0) {
this.x = x
this.y = y
this.z = z
}
}
// Default export for compatibility
export default {
Scene,
OrthographicCamera,
PerspectiveCamera,
WebGLRenderer,
PlaneGeometry,
BoxGeometry,
ShaderMaterial,
MeshBasicMaterial,
Mesh,
Texture,
DataTexture,
TextureLoader,
RGBAFormat,
FloatType,
LinearFilter,
Vector2,
Vector3
}

@ -0,0 +1,419 @@
/**
* 测试数据工厂
* 生成测试所需的模拟数据
*/
// ==================== 用户数据工厂 ====================
let userIdCounter = 1
/**
* 用户数据工厂
*/
export const UserFactory = {
/**
* 创建普通用户
*/
create(overrides = {}) {
const id = userIdCounter++
return {
user_id: id,
username: `user_${id}`,
email: `user_${id}@example.com`,
role: 'user',
is_active: true,
created_at: new Date().toISOString(),
...overrides
}
},
/**
* 创建管理员用户
*/
createAdmin(overrides = {}) {
return this.create({
username: `admin_${userIdCounter}`,
email: `admin_${userIdCounter}@example.com`,
role: 'admin',
...overrides
})
},
/**
* 创建 VIP 用户
*/
createVip(overrides = {}) {
return this.create({
username: `vip_${userIdCounter}`,
email: `vip_${userIdCounter}@example.com`,
role: 'vip',
...overrides
})
},
/**
* 创建登录响应数据
*/
createLoginResponse(user = null) {
const userData = user || this.create()
return {
access_token: `test_token_${Date.now()}`,
user: userData
}
},
/**
* 重置计数器
*/
reset() {
userIdCounter = 1
}
}
// ==================== 任务数据工厂 ====================
let taskIdCounter = 1
let flowIdCounter = 1000001
/**
* 任务数据工厂
*/
export const TaskFactory = {
/**
* 创建加噪任务
*/
createPerturbation(overrides = {}) {
const id = taskIdCounter++
const flowId = flowIdCounter++
return {
task_id: id,
flow_id: flowId,
task_type: 'perturbation',
status: 'waiting',
description: `测试加噪任务 #${id}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
perturbation: {
perturbation_intensity: 0.5,
perturbation_name: 'Glaze',
data_type: 'facial'
},
...overrides
}
},
/**
* 创建微调任务
*/
createFinetune(overrides = {}) {
const id = taskIdCounter++
const flowId = flowIdCounter++
return {
task_id: id,
flow_id: flowId,
task_type: 'finetune',
status: 'waiting',
description: `测试微调任务 #${id}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
finetune: {
finetune_name: 'LoRA',
data_type: 'facial'
},
...overrides
}
},
/**
* 创建热力图任务
*/
createHeatmap(overrides = {}) {
const id = taskIdCounter++
const flowId = flowIdCounter++
return {
task_id: id,
flow_id: flowId,
task_type: 'heatmap',
status: 'waiting',
description: `测试热力图任务 #${id}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
heatmap: {
heatmap_name: '热力图分析'
},
...overrides
}
},
/**
* 创建评估任务
*/
createEvaluate(overrides = {}) {
const id = taskIdCounter++
const flowId = flowIdCounter++
return {
task_id: id,
flow_id: flowId,
task_type: 'evaluate',
status: 'waiting',
description: `测试评估任务 #${id}`,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
evaluate: {
evaluate_name: '效果评估'
},
...overrides
}
},
/**
* 创建任务列表
*/
createList(count = 5, type = 'perturbation') {
const tasks = []
const createFn = {
perturbation: this.createPerturbation.bind(this),
finetune: this.createFinetune.bind(this),
heatmap: this.createHeatmap.bind(this),
evaluate: this.createEvaluate.bind(this)
}[type] || this.createPerturbation.bind(this)
for (let i = 0; i < count; i++) {
tasks.push(createFn())
}
return tasks
},
/**
* 创建任务配额数据
*/
createQuota(overrides = {}) {
return {
max_tasks: 5,
current_tasks: 2,
remaining_tasks: 3,
...overrides
}
},
/**
* 重置计数器
*/
reset() {
taskIdCounter = 1
flowIdCounter = 1000001
}
}
// ==================== 图片数据工厂 ====================
let imageIdCounter = 1
/**
* 图片数据工厂
*/
export const ImageFactory = {
/**
* 创建原图数据
*/
createOriginal(overrides = {}) {
const id = imageIdCounter++
return {
image_id: id,
image_type: 'original',
stored_filename: `original_${id}.png`,
file_path: `/tmp/original_${id}.png`,
file_size: 1024 * Math.floor(Math.random() * 100 + 1),
width: 512,
height: 512,
created_at: new Date().toISOString(),
...overrides
}
},
/**
* 创建加噪图数据
*/
createPerturbed(overrides = {}) {
const id = imageIdCounter++
return {
image_id: id,
image_type: 'perturbed',
stored_filename: `perturbed_${id}.png`,
file_path: `/tmp/perturbed_${id}.png`,
file_size: 1024 * Math.floor(Math.random() * 100 + 1),
width: 512,
height: 512,
created_at: new Date().toISOString(),
...overrides
}
},
/**
* 创建生成图数据
*/
createGenerated(overrides = {}) {
const id = imageIdCounter++
return {
image_id: id,
image_type: 'generated',
stored_filename: `generated_${id}.png`,
file_path: `/tmp/generated_${id}.png`,
file_size: 1024 * Math.floor(Math.random() * 100 + 1),
width: 512,
height: 512,
created_at: new Date().toISOString(),
...overrides
}
},
/**
* 创建热力图数据
*/
createHeatmap(overrides = {}) {
const id = imageIdCounter++
return {
image_id: id,
image_type: 'heatmap',
stored_filename: `heatmap_${id}.png`,
file_path: `/tmp/heatmap_${id}.png`,
file_size: 1024 * Math.floor(Math.random() * 100 + 1),
width: 512,
height: 512,
created_at: new Date().toISOString(),
...overrides
}
},
/**
* 创建图片预览响应数据
*/
createPreviewResponse(counts = { original: 2, perturbed: 2 }) {
const images = {
original: [],
perturbed: [],
original_generate: [],
perturbed_generate: [],
uploaded_generate: [],
heatmap: [],
report: []
}
for (let i = 0; i < (counts.original || 0); i++) {
images.original.push({
image_id: imageIdCounter++,
data: `blob:http://localhost/original_${i}`,
blob: new Blob(['fake image data'], { type: 'image/png' })
})
}
for (let i = 0; i < (counts.perturbed || 0); i++) {
images.perturbed.push({
image_id: imageIdCounter++,
data: `blob:http://localhost/perturbed_${i}`,
blob: new Blob(['fake image data'], { type: 'image/png' })
})
}
return { images }
},
/**
* 创建 Multipart 响应的 ArrayBuffer
*/
createMultipartBuffer(boundary, parts) {
const encoder = new TextEncoder()
let content = ''
parts.forEach((part, index) => {
content += `--${boundary}\r\n`
content += `Content-Type: ${part.contentType || 'image/png'}\r\n`
content += `X-Image-Type: ${part.imageType || 'original'}\r\n`
content += `X-Image-Id: ${part.imageId || index + 1}\r\n`
content += '\r\n'
content += part.data || 'fake image data'
content += '\r\n'
})
content += `--${boundary}--\r\n`
return encoder.encode(content).buffer
},
/**
* 重置计数器
*/
reset() {
imageIdCounter = 1
}
}
// ==================== 配置数据工厂 ====================
/**
* 配置数据工厂
*/
export const ConfigFactory = {
/**
* 创建加噪配置
*/
createPerturbationConfig(overrides = {}) {
return {
perturbation_configs_id: 1,
perturbation_code: 'glaze',
perturbation_name: 'Glaze',
description: 'Glaze 加噪算法',
...overrides
}
},
/**
* 创建微调配置
*/
createFinetuneConfig(overrides = {}) {
return {
finetune_configs_id: 1,
finetune_code: 'lora',
finetune_name: 'LoRA',
description: 'LoRA 微调',
...overrides
}
},
/**
* 创建数据类型配置
*/
createDataType(overrides = {}) {
return {
data_type_id: 1,
data_type_code: 'facial',
instance_prompt: 'a photo of sks person',
class_prompt: 'a photo of person',
description: '人脸数据集',
...overrides
}
},
/**
* 创建风格预设
*/
createStylePreset(overrides = {}) {
return {
style_id: 1,
style_name: '油画风格',
style_code: 'oil_painting',
preview_url: '/styles/oil_painting.jpg',
...overrides
}
}
}
// ==================== 重置所有工厂 ====================
/**
* 重置所有工厂的计数器
*/
export function resetAllFactories() {
UserFactory.reset()
TaskFactory.reset()
ImageFactory.reset()
}

@ -0,0 +1,4 @@
/**
* 集成测试包
* 测试组件和视图的交互
*/

@ -0,0 +1,155 @@
/**
* Button 组件集成测试
* 测试按钮组件的渲染和交互
*
* 组件实际结构
* - 根元素: button.kt-close-button
* - 变体类: kt-close-button--{variant} (primary, secondary, ghost)
* - 尺寸类: kt-close-button--{size} (sm, md, lg)
* - 禁用类: kt-close-button--disabled
* - 导航展开类: kt-close-button--nav-expanded
*/
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from '@/components/Button.vue'
describe('Button 组件', () => {
// ==================== 渲染测试 ====================
describe('渲染', () => {
it('应正确渲染默认按钮', () => {
const wrapper = mount(Button)
expect(wrapper.find('button').exists()).toBe(true)
expect(wrapper.find('.kt-close-button').exists()).toBe(true)
expect(wrapper.text()).toContain('返回')
})
it('应正确渲染不同变体的按钮', () => {
const variants = ['primary', 'secondary', 'ghost']
variants.forEach(variant => {
const wrapper = mount(Button, {
props: { variant }
})
expect(wrapper.find(`.kt-close-button--${variant}`).exists()).toBe(true)
})
})
it('应正确渲染不同尺寸的按钮', () => {
const sizes = ['sm', 'md', 'lg']
sizes.forEach(size => {
const wrapper = mount(Button, {
props: { size }
})
expect(wrapper.find(`.kt-close-button--${size}`).exists()).toBe(true)
})
})
it('禁用状态应添加 disabled 属性和类名', () => {
const wrapper = mount(Button, {
props: { disabled: true }
})
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
expect(wrapper.find('.kt-close-button--disabled').exists()).toBe(true)
})
it('导航展开状态应添加对应类名', () => {
const wrapper = mount(Button, {
props: { navExpanded: true }
})
expect(wrapper.find('.kt-close-button--nav-expanded').exists()).toBe(true)
})
it('应包含图标和文本', () => {
const wrapper = mount(Button)
expect(wrapper.find('.kt-close-button__icon').exists()).toBe(true)
expect(wrapper.find('.kt-close-button__text').exists()).toBe(true)
})
it('应有正确的 aria-label', () => {
const wrapper = mount(Button)
expect(wrapper.find('button').attributes('aria-label')).toBe('返回')
})
})
// ==================== 交互测试 ====================
describe('交互', () => {
it('点击应触发 close 事件', async () => {
const wrapper = mount(Button)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')).toHaveLength(1)
})
it('禁用状态下点击不应触发事件', async () => {
const wrapper = mount(Button, {
props: { disabled: true }
})
await wrapper.find('button').trigger('click')
// 禁用按钮的点击事件不会被触发
expect(wrapper.emitted('close')).toBeFalsy()
})
})
// ==================== Props 验证测试 ====================
describe('Props 验证', () => {
it('variant 默认值应为 primary', () => {
const wrapper = mount(Button)
expect(wrapper.find('.kt-close-button--primary').exists()).toBe(true)
})
it('size 默认值应为 md', () => {
const wrapper = mount(Button)
expect(wrapper.find('.kt-close-button--md').exists()).toBe(true)
})
it('disabled 默认值应为 false', () => {
const wrapper = mount(Button)
expect(wrapper.find('.kt-close-button--disabled').exists()).toBe(false)
})
it('navExpanded 默认值应为 false', () => {
const wrapper = mount(Button)
expect(wrapper.find('.kt-close-button--nav-expanded').exists()).toBe(false)
})
})
// ==================== 样式测试 ====================
describe('样式', () => {
it('应有固定定位样式', () => {
const wrapper = mount(Button)
// 检查组件是否有 kt-close-button 类(包含固定定位样式)
expect(wrapper.find('.kt-close-button').exists()).toBe(true)
})
it('图标应有正确的类名', () => {
const wrapper = mount(Button)
const icon = wrapper.find('.kt-close-button__icon')
expect(icon.exists()).toBe(true)
expect(icon.classes()).toContain('fas')
expect(icon.classes()).toContain('fa-arrow-left')
})
})
})

@ -0,0 +1,214 @@
/**
* ImagePreviewModal 组件集成测试
* 测试图片预览模态框组件的渲染和交互
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises, config } from '@vue/test-utils'
// Mock API - 使用 vi.hoisted 确保在 mock 之前定义
const { mockGetTaskImagePreview } = vi.hoisted(() => ({
mockGetTaskImagePreview: vi.fn()
}))
vi.mock('@/api/image', () => ({
getTaskImagePreview: mockGetTaskImagePreview
}))
// Mock ThreeDTrajectoryModal 组件
vi.mock('@/components/ThreeDTrajectoryModal.vue', () => ({
default: {
name: 'ThreeDTrajectoryModal',
template: '<div class="mock-3d-modal"></div>',
props: ['isOpen', 'taskId']
}
}))
import ImagePreviewModal from '@/components/ImagePreviewModal.vue'
// 禁用 Teleport 以便测试
config.global.stubs = {
...config.global.stubs,
teleport: true
}
describe('ImagePreviewModal 组件', () => {
beforeEach(() => {
vi.clearAllMocks()
document.body.innerHTML = ''
})
afterEach(() => {
document.body.innerHTML = ''
})
// ==================== 渲染测试 ====================
describe('渲染', () => {
it('isOpen 为 true 时应渲染模态框', async () => {
mockGetTaskImagePreview.mockResolvedValue({
images: {
original: [{ data: 'blob:test1', image_id: 1 }],
perturbed: [{ data: 'blob:test2', image_id: 2 }]
}
})
const wrapper = mount(ImagePreviewModal, {
props: {
isOpen: true,
taskId: 1,
taskType: 'perturbation'
}
})
await flushPromises()
expect(wrapper.find('.kt-preview-overlay').exists()).toBe(true)
wrapper.unmount()
})
it('isOpen 为 false 时不应渲染模态框', () => {
const wrapper = mount(ImagePreviewModal, {
props: {
isOpen: false,
taskId: 1,
taskType: 'perturbation'
}
})
expect(wrapper.find('.kt-preview-overlay').exists()).toBe(false)
wrapper.unmount()
})
it('应显示任务 ID 标签', async () => {
mockGetTaskImagePreview.mockResolvedValue({
images: {
original: [{ data: 'blob:test', image_id: 1 }]
}
})
const wrapper = mount(ImagePreviewModal, {
props: {
isOpen: true,
taskId: 123,
taskType: 'perturbation'
}
})
await flushPromises()
expect(wrapper.text()).toContain('123')
wrapper.unmount()
})
})
// ==================== 图片展示测试 ====================
describe('图片展示', () => {
it('应正确渲染图片框', async () => {
mockGetTaskImagePreview.mockResolvedValue({
images: {
original: [{ data: 'blob:original', image_id: 1 }],
perturbed: [{ data: 'blob:perturbed', image_id: 2 }]
}
})
const wrapper = mount(ImagePreviewModal, {
props: {
isOpen: true,
taskId: 1,
taskType: 'perturbation'
}
})
await flushPromises()
expect(wrapper.find('.kt-preview-img-box').exists()).toBe(true)
wrapper.unmount()
})
it('微调任务应显示模式标签', async () => {
mockGetTaskImagePreview.mockResolvedValue({
images: {
uploaded: [{ data: 'blob:uploaded', image_id: 1 }],
uploaded_generate: [{ data: 'blob:generated', image_id: 2 }]
}
})
const wrapper = mount(ImagePreviewModal, {
props: {
isOpen: true,
taskId: 1,
taskType: 'finetune'
}
})
await flushPromises()
expect(wrapper.find('.kt-preview-mode-tag').exists()).toBe(true)
wrapper.unmount()
})
})
// ==================== 3D 轨迹测试 ====================
describe('3D 轨迹', () => {
it('微调任务应显示 3D 轨迹按钮', async () => {
mockGetTaskImagePreview.mockResolvedValue({
images: {
uploaded: [{ data: 'blob:test', image_id: 1 }],
uploaded_generate: [{ data: 'blob:test2', image_id: 2 }]
}
})
const wrapper = mount(ImagePreviewModal, {
props: {
isOpen: true,
taskId: 1,
taskType: 'finetune'
}
})
await flushPromises()
expect(wrapper.text()).toContain('3D')
wrapper.unmount()
})
})
// ==================== 关闭测试 ====================
describe('关闭', () => {
it('点击关闭按钮应触发 close 事件', async () => {
mockGetTaskImagePreview.mockResolvedValue({
images: {
original: [{ data: 'blob:test', image_id: 1 }]
}
})
const wrapper = mount(ImagePreviewModal, {
props: {
isOpen: true,
taskId: 1,
taskType: 'perturbation'
}
})
await flushPromises()
const closeBtn = wrapper.find('.kt-preview-close-btn')
if (closeBtn.exists()) {
await closeBtn.trigger('click')
expect(wrapper.emitted('close')).toBeTruthy()
}
wrapper.unmount()
})
})
})

@ -0,0 +1,347 @@
/**
* KtModal 组件集成测试
* 测试模态框组件的渲染和交互
*
* 组件实际结构
* - 遮罩层: div.kt-modal-overlay
* - 模态框: div.kt-modal
* - 类型类: kt-modal--{type} (info, success, warning, error)
* - 尺寸类: kt-modal--{size} (small, normal, large)
* - 图标: div.kt-modal__icon > i.fas
* - 标题: h3.kt-modal__title
* - 消息: p.kt-modal__message
* - 按钮区: div.kt-modal__actions
* - 取消按钮: button.kt-modal__btn.kt-modal__btn--cancel
* - 确认按钮: button.kt-modal__btn.kt-modal__btn--confirm
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import KtModal from '@/components/KtModal.vue'
describe('KtModal 组件', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// ==================== 渲染测试 ====================
describe('渲染', () => {
it('挂载后应渲染模态框', async () => {
const wrapper = mount(KtModal, {
props: {
message: '测试消息'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
// 组件使用 Teleport 到 body
expect(document.querySelector('.kt-modal-overlay')).toBeTruthy()
expect(document.querySelector('.kt-modal')).toBeTruthy()
wrapper.unmount()
})
it('应正确渲染消息内容', async () => {
const wrapper = mount(KtModal, {
props: {
message: '这是测试消息'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal__message')?.textContent).toBe('这是测试消息')
wrapper.unmount()
})
it('应正确渲染自定义标题', async () => {
const wrapper = mount(KtModal, {
props: {
title: '自定义标题',
message: '消息'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal__title')?.textContent).toBe('自定义标题')
wrapper.unmount()
})
it('应正确渲染不同类型的模态框', async () => {
const types = ['info', 'success', 'warning', 'error']
for (const type of types) {
const wrapper = mount(KtModal, {
props: {
type,
message: 'Test'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector(`.kt-modal--${type}`)).toBeTruthy()
wrapper.unmount()
}
})
it('showCancel 为 true 时应显示取消按钮', async () => {
const wrapper = mount(KtModal, {
props: {
message: 'Test',
showCancel: true
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal__btn--cancel')).toBeTruthy()
wrapper.unmount()
})
it('showCancel 为 false 时不应显示取消按钮', async () => {
const wrapper = mount(KtModal, {
props: {
message: 'Test',
showCancel: false
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal__btn--cancel')).toBeFalsy()
wrapper.unmount()
})
})
// ==================== 交互测试 ====================
describe('交互', () => {
it('点击确认按钮应触发 onConfirm 和 onClose', async () => {
const onConfirm = vi.fn()
const onClose = vi.fn()
const wrapper = mount(KtModal, {
props: {
message: 'Test',
onConfirm,
onClose
},
attachTo: document.body
})
await vi.runAllTimersAsync()
const confirmBtn = document.querySelector('.kt-modal__btn--confirm')
confirmBtn?.dispatchEvent(new Event('click'))
// 等待动画完成
await vi.advanceTimersByTimeAsync(300)
expect(onConfirm).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
wrapper.unmount()
})
it('点击取消按钮应触发 onCancel 和 onClose', async () => {
const onCancel = vi.fn()
const onClose = vi.fn()
const wrapper = mount(KtModal, {
props: {
message: 'Test',
showCancel: true,
onCancel,
onClose
},
attachTo: document.body
})
await vi.runAllTimersAsync()
const cancelBtn = document.querySelector('.kt-modal__btn--cancel')
cancelBtn?.dispatchEvent(new Event('click'))
// 等待动画完成
await vi.advanceTimersByTimeAsync(300)
expect(onCancel).toHaveBeenCalled()
expect(onClose).toHaveBeenCalled()
wrapper.unmount()
})
it('点击遮罩层应关闭closeOnOverlay 为 true', async () => {
const onClose = vi.fn()
const wrapper = mount(KtModal, {
props: {
message: 'Test',
closeOnOverlay: true,
onClose
},
attachTo: document.body
})
await vi.runAllTimersAsync()
const overlay = document.querySelector('.kt-modal-overlay')
overlay?.dispatchEvent(new Event('click'))
// 等待动画完成
await vi.advanceTimersByTimeAsync(300)
expect(onClose).toHaveBeenCalled()
wrapper.unmount()
})
it('closeOnOverlay 为 false 时点击遮罩不应关闭', async () => {
const onClose = vi.fn()
const wrapper = mount(KtModal, {
props: {
message: 'Test',
closeOnOverlay: false,
onClose
},
attachTo: document.body
})
await vi.runAllTimersAsync()
// 点击遮罩层不应触发关闭
// 注意:由于 @click.self点击模态框内部不会触发
wrapper.unmount()
})
})
// ==================== 尺寸测试 ====================
describe('尺寸', () => {
it('应支持 small 尺寸', async () => {
const wrapper = mount(KtModal, {
props: {
message: 'Test',
size: 'small'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal--small')).toBeTruthy()
wrapper.unmount()
})
it('应支持 large 尺寸', async () => {
const wrapper = mount(KtModal, {
props: {
message: 'Test',
size: 'large'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal--large')).toBeTruthy()
wrapper.unmount()
})
})
// ==================== 图标测试 ====================
describe('图标', () => {
it('success 类型应显示 check-circle 图标', async () => {
const wrapper = mount(KtModal, {
props: {
type: 'success',
message: 'Test'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal__icon .fa-check-circle')).toBeTruthy()
wrapper.unmount()
})
it('error 类型应显示 times-circle 图标', async () => {
const wrapper = mount(KtModal, {
props: {
type: 'error',
message: 'Test'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal__icon .fa-times-circle')).toBeTruthy()
wrapper.unmount()
})
})
// ==================== 按钮文本测试 ====================
describe('按钮文本', () => {
it('应支持自定义确认按钮文本', async () => {
const wrapper = mount(KtModal, {
props: {
message: 'Test',
confirmText: '好的'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal__btn--confirm')?.textContent?.trim()).toBe('好的')
wrapper.unmount()
})
it('应支持自定义取消按钮文本', async () => {
const wrapper = mount(KtModal, {
props: {
message: 'Test',
showCancel: true,
cancelText: '算了'
},
attachTo: document.body
})
await vi.runAllTimersAsync()
expect(document.querySelector('.kt-modal__btn--cancel')?.textContent?.trim()).toBe('算了')
wrapper.unmount()
})
})
})

@ -0,0 +1,267 @@
/**
* NavBar 组件集成测试
* 测试导航栏组件的渲染和交互
*
* 组件实际结构
* - 容器: div#navbar-container.kt-navbar-container
* - 导航栏: div#nav-bar.kt-navbar
* - 头部: div#nav-header.kt-navbar__header
* - 标题: a#nav-title.kt-navbar__title
* - 汉堡按钮: span#nav-toggle-burger.kt-navbar__burger
* - 内容区: nav#nav-content.kt-navbar__content
* - 高亮: div#nav-content-highlight.kt-navbar__highlight
* - 导航项: div.nav-button.kt-navbar__item
* - 图标: i.kt-navbar__icon
* - 标签: span.kt-navbar__label
* - 外部按钮: div.external-actions.kt-navbar__actions
* - 主题按钮: button.kt-btn.theme-btn
* - 个人中心: button.kt-btn.page-5-btn
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import NavBar from '@/components/NavBar.vue'
// Mock router
const mockRouter = {
push: vi.fn(),
currentRoute: {
value: {
path: '/',
name: 'home'
}
}
}
// Mock useRouter
vi.mock('vue-router', () => ({
useRouter: () => mockRouter,
useRoute: () => mockRouter.currentRoute.value
}))
describe('NavBar 组件', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
// 清理 localStorage
localStorage.clear()
})
// ==================== 渲染测试 ====================
describe('渲染', () => {
it('应正确渲染导航栏容器', () => {
const wrapper = mount(NavBar)
expect(wrapper.find('#navbar-container').exists()).toBe(true)
expect(wrapper.find('.kt-navbar-container').exists()).toBe(true)
})
it('应渲染导航栏主体', () => {
const wrapper = mount(NavBar)
expect(wrapper.find('#nav-bar').exists()).toBe(true)
expect(wrapper.find('.kt-navbar').exists()).toBe(true)
})
it('应渲染所有导航项', () => {
const wrapper = mount(NavBar)
const navItems = wrapper.findAll('.nav-button')
expect(navItems.length).toBe(5) // home, page1, page2, page3, page4
})
it('应渲染汉堡菜单按钮', () => {
const wrapper = mount(NavBar)
expect(wrapper.find('#nav-toggle-burger').exists()).toBe(true)
})
it('应渲染外部操作按钮', () => {
const wrapper = mount(NavBar)
expect(wrapper.find('.external-actions').exists()).toBe(true)
expect(wrapper.find('.theme-btn').exists()).toBe(true)
expect(wrapper.find('.page-5-btn').exists()).toBe(true)
})
})
// ==================== 高亮测试 ====================
describe('高亮', () => {
it('当前 section 对应的导航项应高亮', () => {
const wrapper = mount(NavBar, {
props: {
currentSection: 'page1'
}
})
const activeItem = wrapper.find('.nav-button.active')
expect(activeItem.exists()).toBe(true)
})
it('高亮指示器应存在', () => {
const wrapper = mount(NavBar, {
props: {
currentSection: 'home'
}
})
expect(wrapper.find('#nav-content-highlight').exists()).toBe(true)
})
})
// ==================== 交互测试 ====================
describe('交互', () => {
it('点击导航项应触发 navigate 事件', async () => {
const wrapper = mount(NavBar)
const navItem = wrapper.findAll('.nav-button')[1] // page1
await navItem.trigger('click')
expect(wrapper.emitted('navigate')).toBeTruthy()
expect(wrapper.emitted('navigate')[0]).toEqual(['page1'])
})
it('点击登出按钮应触发 logout 事件', async () => {
const wrapper = mount(NavBar)
// 组件中没有直接的登出按钮,但有个人中心按钮
const page5Btn = wrapper.find('.page-5-btn')
await page5Btn.trigger('click')
expect(wrapper.emitted('navigate')).toBeTruthy()
expect(wrapper.emitted('navigate')[0]).toEqual(['page5'])
})
it('点击主题切换按钮应切换主题', async () => {
const wrapper = mount(NavBar)
const themeBtn = wrapper.find('.theme-btn')
// 初始状态是深色模式
expect(wrapper.vm.isDarkMode).toBe(true)
await themeBtn.trigger('click')
expect(wrapper.vm.isDarkMode).toBe(false)
})
it('键盘 Enter 应触发导航', async () => {
const wrapper = mount(NavBar)
const navItem = wrapper.findAll('.nav-button')[0]
await navItem.trigger('keydown.enter')
expect(wrapper.emitted('navigate')).toBeTruthy()
})
it('键盘 Space 应触发导航', async () => {
const wrapper = mount(NavBar)
const navItem = wrapper.findAll('.nav-button')[0]
await navItem.trigger('keydown.space')
expect(wrapper.emitted('navigate')).toBeTruthy()
})
})
// ==================== 展开/收起测试 ====================
describe('展开/收起', () => {
it('切换展开状态应触发 toggle 事件', async () => {
const wrapper = mount(NavBar)
// 通过 checkbox 切换
const checkbox = wrapper.find('#nav-toggle')
await checkbox.setValue(true)
expect(wrapper.emitted('toggle')).toBeTruthy()
expect(wrapper.emitted('toggle')[0]).toEqual([true])
})
it('展开状态应保存到 localStorage', async () => {
const wrapper = mount(NavBar)
const checkbox = wrapper.find('#nav-toggle')
await checkbox.setValue(true)
// localStorage.setItem 会将 boolean 转为 string但 Vue 的 watch 传递的是 boolean
expect(localStorage.setItem).toHaveBeenCalledWith('kt_nav_expanded', true)
})
})
// ==================== 无障碍测试 ====================
describe('无障碍', () => {
it('导航区域应有正确的 role', () => {
const wrapper = mount(NavBar)
expect(wrapper.find('[role="navigation"]').exists()).toBe(true)
})
it('导航项应有 role="menuitem"', () => {
const wrapper = mount(NavBar)
const navItems = wrapper.findAll('[role="menuitem"]')
expect(navItems.length).toBe(5)
})
it('当前导航项应有 aria-current="page"', () => {
const wrapper = mount(NavBar, {
props: {
currentSection: 'home'
}
})
const currentItem = wrapper.find('[aria-current="page"]')
expect(currentItem.exists()).toBe(true)
})
it('导航项应可通过 Tab 聚焦', () => {
const wrapper = mount(NavBar)
const navItems = wrapper.findAll('.nav-button')
navItems.forEach(item => {
expect(item.attributes('tabindex')).toBe('0')
})
})
it('图标应对屏幕阅读器隐藏', () => {
const wrapper = mount(NavBar)
const icons = wrapper.findAll('.kt-navbar__icon')
icons.forEach(icon => {
expect(icon.attributes('aria-hidden')).toBe('true')
})
})
})
// ==================== 图标测试 ====================
describe('图标', () => {
it('每个导航项应有图标', () => {
const wrapper = mount(NavBar)
const navItems = wrapper.findAll('.nav-button')
navItems.forEach(item => {
expect(item.find('i.fas').exists()).toBe(true)
})
})
it('主题按钮图标应根据模式变化', async () => {
const wrapper = mount(NavBar)
// 深色模式显示太阳图标
expect(wrapper.find('.theme-btn .fa-cloud-sun').exists()).toBe(true)
// 切换到亮色模式
await wrapper.find('.theme-btn').trigger('click')
// 亮色模式显示月亮图标
expect(wrapper.find('.theme-btn .fa-moon').exists()).toBe(true)
})
})
})

@ -0,0 +1,231 @@
/**
* Toast 组件集成测试
* 测试消息提示组件的渲染和交互
*
* 注意Toast 组件使用 Transition需要等待动画完成
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount } from '@vue/test-utils'
import Toast from '@/components/Toast.vue'
describe('Toast 组件', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// ==================== 渲染测试 ====================
describe('渲染', () => {
it('应正确渲染消息内容', async () => {
const wrapper = mount(Toast, {
props: {
message: '操作成功',
type: 'success'
}
})
// 等待 onMounted 设置 visible = true 并等待 Transition
await vi.advanceTimersByTimeAsync(100)
await wrapper.vm.$nextTick()
// Toast 使用 v-if需要检查 wrapper 内部
expect(wrapper.text()).toContain('操作成功')
})
it('应正确渲染不同类型的 Toast', async () => {
const types = ['success', 'error', 'warning', 'info']
for (const type of types) {
const wrapper = mount(Toast, {
props: {
message: 'Test',
type
}
})
await vi.advanceTimersByTimeAsync(100)
await wrapper.vm.$nextTick()
// 检查组件内部是否有对应类型的类
expect(wrapper.html()).toContain(`kt-toast--${type}`)
wrapper.unmount()
}
})
it('应显示正确的标题', async () => {
const typeToTitle = {
success: 'Success',
error: 'Error',
warning: 'Warning',
info: 'Notice'
}
for (const [type, title] of Object.entries(typeToTitle)) {
const wrapper = mount(Toast, {
props: {
message: 'Test',
type
}
})
await vi.advanceTimersByTimeAsync(100)
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain(title)
wrapper.unmount()
}
})
})
// ==================== 自动关闭测试 ====================
describe('自动关闭', () => {
it('应在指定时间后自动关闭', async () => {
const onClose = vi.fn()
const wrapper = mount(Toast, {
props: {
message: '操作成功',
duration: 3000,
onClose,
id: 'test-toast'
}
})
// 等待 onMounted
await vi.advanceTimersByTimeAsync(100)
// 前进 3 秒触发关闭
await vi.advanceTimersByTimeAsync(3000)
// 等待动画完成 (300ms)
await vi.advanceTimersByTimeAsync(300)
expect(onClose).toHaveBeenCalledWith('test-toast')
})
it('duration 为 0 时不应自动关闭', async () => {
const onClose = vi.fn()
const wrapper = mount(Toast, {
props: {
message: '操作成功',
duration: 0,
onClose
}
})
// 前进 10 秒
await vi.advanceTimersByTimeAsync(10000)
expect(onClose).not.toHaveBeenCalled()
})
})
// ==================== 图标测试 ====================
describe('图标', () => {
it('success 类型应显示 check-circle 图标', async () => {
const wrapper = mount(Toast, {
props: {
message: '操作成功',
type: 'success'
}
})
await vi.advanceTimersByTimeAsync(100)
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('fa-check-circle')
})
it('error 类型应显示 times-circle 图标', async () => {
const wrapper = mount(Toast, {
props: {
message: '操作失败',
type: 'error'
}
})
await vi.advanceTimersByTimeAsync(100)
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('fa-times-circle')
})
it('warning 类型应显示 exclamation-triangle 图标', async () => {
const wrapper = mount(Toast, {
props: {
message: '警告',
type: 'warning'
}
})
await vi.advanceTimersByTimeAsync(100)
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('fa-exclamation-triangle')
})
it('info 类型应显示 info-circle 图标', async () => {
const wrapper = mount(Toast, {
props: {
message: '提示',
type: 'info'
}
})
await vi.advanceTimersByTimeAsync(100)
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('fa-info-circle')
})
})
// ==================== 位置测试 ====================
describe('位置', () => {
it('应支持自定义 offset', async () => {
const wrapper = mount(Toast, {
props: {
message: 'Test',
offset: 50
}
})
await vi.advanceTimersByTimeAsync(100)
await wrapper.vm.$nextTick()
expect(wrapper.html()).toContain('top: 50px')
})
})
// ==================== 暴露方法测试 ====================
describe('暴露方法', () => {
it('应暴露 close 方法', async () => {
const onClose = vi.fn()
const wrapper = mount(Toast, {
props: {
message: 'Test',
duration: 0,
onClose,
id: 'test'
}
})
await vi.advanceTimersByTimeAsync(100)
// 调用暴露的 close 方法
wrapper.vm.close()
// 等待动画完成
await vi.advanceTimersByTimeAsync(300)
expect(onClose).toHaveBeenCalledWith('test')
})
})
})

@ -0,0 +1,211 @@
/**
* LoginView 视图集成测试
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { ref, defineComponent, h } from 'vue'
import { UserFactory } from '../../factories'
const mockAuthLogin = vi.fn()
const mockRouterPush = vi.fn()
const mockSetLoginData = vi.fn()
const LoginViewTestComponent = defineComponent({
name: 'LoginViewTest',
setup() {
const flowMode = ref('login')
const loading = ref(false)
const showPassword = ref(false)
const isDark = ref(true)
const errorMessage = ref('')
const form = ref({ username: '', password: '' })
const handleLogin = async () => {
errorMessage.value = ''
if (!form.value.username || !form.value.password) {
errorMessage.value = '请输入用户名和密码'
return
}
loading.value = true
try {
const res = await mockAuthLogin({
username: form.value.username,
password: form.value.password
})
if (res.access_token) {
mockSetLoginData(res)
mockRouterPush('/')
}
} catch (error) {
errorMessage.value = error.message || '登录失败'
} finally {
loading.value = false
}
}
return { flowMode, loading, showPassword, isDark, errorMessage, form, handleLogin }
},
render() {
const self = this
return h('div', { class: 'kt-login-container' }, [
h('button', { class: 'kt-theme-toggle', onClick: () => { self.isDark = !self.isDark } }),
self.flowMode === 'login'
? h('div', { class: 'kt-login-card' }, [
h('div', { class: 'kt-brand-side' }, [h('div', { class: 'kt-logo-text' }, 'MUSE GUARD')]),
h('div', { class: 'kt-form-side' }, [
h('input', { type: 'text', name: 'username', value: self.form.username, onInput: (e) => { self.form.username = e.target.value } }),
h('input', { type: self.showPassword ? 'text' : 'password', name: 'password', value: self.form.password, onInput: (e) => { self.form.password = e.target.value } }),
h('i', { class: 'kt-toggle-password', onClick: () => { self.showPassword = !self.showPassword } }),
self.errorMessage ? h('div', { class: 'kt-error-tip' }, self.errorMessage) : null,
h('div', { class: 'kt-forgot-link' }, [h('a', { href: '#', onClick: (e) => { e.preventDefault(); self.flowMode = 'forgot' } }, '忘记密码')]),
h('button', { class: 'kt-btn kt-btn--primary', disabled: self.loading, onClick: self.handleLogin }, self.loading ? '登录中...' : '登录'),
h('div', { class: 'kt-footer-link' }, [h('a', { href: '#', onClick: (e) => { e.preventDefault(); self.flowMode = 'register' } }, '立即注册')])
])
])
: self.flowMode === 'register'
? h('div', { class: 'kt-login-card kt-register-card' }, [
h('div', { class: 'kt-form-header' }, [h('h1', '创建账户')]),
h('input', { type: 'email', name: 'email' }),
h('input', { type: 'text', name: 'code' })
])
: h('div', { class: 'kt-login-card kt-register-card' }, [h('div', { class: 'kt-form-header' }, [h('h1', '重置密码')])])
])
}
})
describe('LoginView 视图', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
UserFactory.reset()
})
describe('渲染', () => {
it('应正确渲染登录容器', () => {
const wrapper = mount(LoginViewTestComponent)
expect(wrapper.find('.kt-login-container').exists()).toBe(true)
})
it('应渲染登录卡片', () => {
const wrapper = mount(LoginViewTestComponent)
expect(wrapper.find('.kt-login-card').exists()).toBe(true)
})
it('应渲染品牌区域', () => {
const wrapper = mount(LoginViewTestComponent)
expect(wrapper.find('.kt-brand-side').exists()).toBe(true)
expect(wrapper.find('.kt-logo-text').text()).toBe('MUSE GUARD')
})
it('应渲染用户名和密码输入框', () => {
const wrapper = mount(LoginViewTestComponent)
expect(wrapper.find('input[name="username"]').exists()).toBe(true)
expect(wrapper.find('input[name="password"]').exists()).toBe(true)
})
it('应渲染主题切换按钮', () => {
const wrapper = mount(LoginViewTestComponent)
expect(wrapper.find('.kt-theme-toggle').exists()).toBe(true)
})
})
describe('登录', () => {
it('输入用户名和密码后应能提交', async () => {
const loginResponse = UserFactory.createLoginResponse()
mockAuthLogin.mockResolvedValue(loginResponse)
const wrapper = mount(LoginViewTestComponent)
await wrapper.find('input[name="username"]').setValue('testuser')
await wrapper.find('input[name="password"]').setValue('password123')
await wrapper.find('.kt-btn--primary').trigger('click')
await flushPromises()
expect(mockAuthLogin).toHaveBeenCalledWith({ username: 'testuser', password: 'password123' })
})
it('登录成功后应跳转到首页', async () => {
const loginResponse = UserFactory.createLoginResponse()
mockAuthLogin.mockResolvedValue(loginResponse)
const wrapper = mount(LoginViewTestComponent)
await wrapper.find('input[name="username"]').setValue('testuser')
await wrapper.find('input[name="password"]').setValue('password123')
await wrapper.find('.kt-btn--primary').trigger('click')
await flushPromises()
expect(mockRouterPush).toHaveBeenCalledWith('/')
})
it('登录失败应显示错误信息', async () => {
mockAuthLogin.mockRejectedValue(new Error('用户名或密码错误'))
const wrapper = mount(LoginViewTestComponent)
await wrapper.find('input[name="username"]').setValue('testuser')
await wrapper.find('input[name="password"]').setValue('wrongpassword')
await wrapper.find('.kt-btn--primary').trigger('click')
await flushPromises()
expect(wrapper.find('.kt-error-tip').exists()).toBe(true)
})
it('空用户名或密码应显示验证错误', async () => {
const wrapper = mount(LoginViewTestComponent)
await wrapper.find('.kt-btn--primary').trigger('click')
expect(wrapper.find('.kt-error-tip').exists()).toBe(true)
})
})
describe('注册', () => {
it('点击注册链接应切换到注册表单', async () => {
const wrapper = mount(LoginViewTestComponent)
await wrapper.find('.kt-footer-link a').trigger('click')
expect(wrapper.find('.kt-register-card').exists()).toBe(true)
})
it('注册表单应显示额外字段', async () => {
const wrapper = mount(LoginViewTestComponent)
await wrapper.find('.kt-footer-link a').trigger('click')
expect(wrapper.find('input[name="email"]').exists()).toBe(true)
expect(wrapper.find('input[name="code"]').exists()).toBe(true)
})
})
describe('忘记密码', () => {
it('应显示忘记密码链接', () => {
const wrapper = mount(LoginViewTestComponent)
expect(wrapper.find('.kt-forgot-link a').exists()).toBe(true)
})
it('点击忘记密码应显示重置表单', async () => {
const wrapper = mount(LoginViewTestComponent)
await wrapper.find('.kt-forgot-link a').trigger('click')
expect(wrapper.find('.kt-form-header h1').text()).toBe('重置密码')
})
})
describe('主题切换', () => {
it('点击主题按钮应切换主题', async () => {
const wrapper = mount(LoginViewTestComponent)
const initialIsDark = wrapper.vm.isDark
await wrapper.find('.kt-theme-toggle').trigger('click')
expect(wrapper.vm.isDark).toBe(!initialIsDark)
})
})
describe('密码可见性', () => {
it('点击眼睛图标应切换密码可见性', async () => {
const wrapper = mount(LoginViewTestComponent)
const passwordInput = wrapper.find('input[name="password"]')
expect(passwordInput.attributes('type')).toBe('password')
await wrapper.find('.kt-toggle-password').trigger('click')
expect(passwordInput.attributes('type')).toBe('text')
})
})
describe('加载状态', () => {
it('提交时应显示加载状态', async () => {
mockAuthLogin.mockImplementation(() => new Promise(() => {}))
const wrapper = mount(LoginViewTestComponent)
await wrapper.find('input[name="username"]').setValue('testuser')
await wrapper.find('input[name="password"]').setValue('password123')
await wrapper.find('.kt-btn--primary').trigger('click')
expect(wrapper.find('.kt-btn--primary').text()).toContain('登录中')
})
})
})

@ -0,0 +1,462 @@
/**
* MainFlow 视图集成测试
* 测试主流程页面的完整功能
*
* 组件实际结构
* - 主布局: div.kt-layout-main
* - 跳过链接: a.kt-skip-link
* - 内容区: div#main-content.kt-layout-content
* - 滚动容器: div.kt-scroll-container
* - 滚动区域: div.kt-scroll-section
* - 独立页面: div.kt-page-standalone
* - 子页面: div.kt-subpage-wrapper
* - 角落按钮: div.kt-corner-btn-area > button.kt-corner-btn
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import MainFlow from '@/views/MainFlow.vue'
import { useUserStore } from '@/stores/userStore'
import { useTaskStore } from '@/stores/taskStore'
import { UserFactory, TaskFactory } from '../../factories'
// Mock API
vi.mock('@/api/task', () => ({
getTaskList: vi.fn(),
getTaskQuota: vi.fn(),
submitPerturbationTask: vi.fn(),
getTaskStatus: vi.fn()
}))
vi.mock('@/api/auth', () => ({
authGetProfile: vi.fn(),
authLogout: vi.fn()
}))
// Mock modal
vi.mock('@/utils/modal', () => ({
default: {
success: vi.fn(() => Promise.resolve()),
error: vi.fn(() => Promise.resolve()),
warning: vi.fn(() => Promise.resolve()),
confirm: vi.fn(() => Promise.resolve(true))
}
}))
// Mock router
const mockRouter = {
push: vi.fn(),
replace: vi.fn(),
currentRoute: {
value: {
path: '/',
name: 'main'
}
}
}
const mockRoute = {
path: '/',
name: 'main'
}
vi.mock('vue-router', () => ({
useRouter: () => mockRouter,
useRoute: () => mockRoute
}))
// Mock 子组件
vi.mock('@/components/NavBar.vue', () => ({
default: {
name: 'NavBar',
template: '<div class="mock-navbar"></div>',
props: ['currentSection'],
emits: ['navigate', 'logout', 'toggle']
}
}))
vi.mock('@/components/NoiseOverlay.vue', () => ({
default: {
name: 'NoiseOverlay',
template: '<div class="mock-noise-overlay"></div>'
}
}))
vi.mock('@/views/home/HomePage.vue', () => ({
default: {
name: 'HomePage',
template: '<div class="mock-home-page">Home</div>'
}
}))
vi.mock('@/views/Page1/Page1.vue', () => ({
default: {
name: 'Page1',
template: '<div class="mock-page1">Page1</div>'
}
}))
vi.mock('@/views/Page2/Page2.vue', () => ({
default: {
name: 'Page2',
template: '<div class="mock-page2">Page2</div>'
}
}))
vi.mock('@/views/Page3/Page3.vue', () => ({
default: {
name: 'Page3',
template: '<div class="mock-page3">Page3</div>'
}
}))
vi.mock('@/views/Page4/Page4.vue', () => ({
default: {
name: 'Page4',
template: '<div class="mock-page4">Page4</div>'
}
}))
vi.mock('@/views/Page5/Page5.vue', () => ({
default: {
name: 'Page5',
template: '<div class="mock-page5">Page5</div>'
}
}))
import { getTaskList, getTaskQuota } from '@/api/task'
describe('MainFlow 视图', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
UserFactory.reset()
TaskFactory.reset()
// 设置默认 mock 返回值
getTaskList.mockResolvedValue({ tasks: [] })
getTaskQuota.mockResolvedValue(TaskFactory.createQuota())
})
afterEach(() => {
vi.useRealTimers()
})
// ==================== 渲染测试 ====================
describe('渲染', () => {
it('应正确渲染主布局', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('.kt-layout-main').exists()).toBe(true)
})
it('应渲染跳过链接(无障碍)', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('.kt-skip-link').exists()).toBe(true)
})
it('应渲染内容区域', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('#main-content').exists()).toBe(true)
expect(wrapper.find('.kt-layout-content').exists()).toBe(true)
})
it('应渲染滚动容器', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('.kt-scroll-container').exists()).toBe(true)
})
it('应渲染所有滚动区域', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
const sections = wrapper.findAll('.kt-scroll-section')
expect(sections.length).toBe(5) // home, page1, page2, page3, page4
})
it('应渲染导航栏组件', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.findComponent({ name: 'NavBar' }).exists()).toBe(true)
})
it('应渲染角落按钮区域', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('.kt-corner-btn-area').exists()).toBe(true)
expect(wrapper.find('.kt-corner-btn').exists()).toBe(true)
})
})
// ==================== 导航测试 ====================
describe('导航', () => {
it('默认应显示 home 页面', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.vm.currentSection).toBe('home')
})
it('活动区域应有 is-active 类', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
const activeSection = wrapper.find('.kt-scroll-section.is-active')
expect(activeSection.exists()).toBe(true)
})
it('非活动区域应有 is-prev 或 is-next 类', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
const nextSections = wrapper.findAll('.kt-scroll-section.is-next')
expect(nextSections.length).toBeGreaterThan(0)
})
})
// ==================== 任务轮询测试 ====================
describe('任务轮询', () => {
it('进入页面应启动任务轮询', async () => {
const taskStore = useTaskStore()
const startPollingSpy = vi.spyOn(taskStore, 'startPolling')
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(startPollingSpy).toHaveBeenCalled()
})
it('离开页面应停止轮询', async () => {
const taskStore = useTaskStore()
const stopPollingSpy = vi.spyOn(taskStore, 'stopPolling')
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
wrapper.unmount()
expect(stopPollingSpy).toHaveBeenCalled()
})
})
// ==================== 导航展开测试 ====================
describe('导航展开', () => {
it('导航展开时应添加 nav-expanded 类', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
// 模拟导航展开
wrapper.vm.isNavExpanded = true
await wrapper.vm.$nextTick()
expect(wrapper.find('.kt-layout-main.nav-expanded').exists()).toBe(true)
})
})
// ==================== 角落按钮测试 ====================
describe('角落按钮', () => {
it('默认应显示登出按钮', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('.kt-corner-btn .fa-sign-out-alt').exists()).toBe(true)
})
it('子页面模式应显示返回按钮', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
// 模拟子页面模式
wrapper.vm.showSubpage = true
await wrapper.vm.$nextTick()
expect(wrapper.find('.kt-corner-btn .fa-arrow-left').exists()).toBe(true)
})
})
// ==================== Page5 测试 ====================
describe('Page5 独立页面', () => {
it('切换到 page5 应显示独立页面容器', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
// 模拟切换到 page5
wrapper.vm.currentSection = 'page5'
await wrapper.vm.$nextTick()
expect(wrapper.find('.kt-page-standalone').exists()).toBe(true)
expect(wrapper.find('.kt-scroll-container').exists()).toBe(false)
})
})
// ==================== 无障碍测试 ====================
describe('无障碍', () => {
it('主内容区应有 role="main"', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('[role="main"]').exists()).toBe(true)
})
it('跳过链接应指向主内容', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('.kt-skip-link').attributes('href')).toBe('#main-content')
})
it('角落按钮应有 aria-label', async () => {
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(wrapper.find('.kt-corner-btn').attributes('aria-label')).toBeTruthy()
})
})
// ==================== 事件监听测试 ====================
describe('事件监听', () => {
it('挂载时应添加滚动事件监听', async () => {
const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function), { passive: false })
})
it('卸载时应移除事件监听', async () => {
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
const wrapper = mount(MainFlow, {
global: {
stubs: ['router-view']
}
})
await flushPromises()
wrapper.unmount()
expect(removeEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function))
})
})
})

@ -0,0 +1,562 @@
/**
* Vitest 测试环境配置
* 提供测试所需的全局配置和 Mock
*/
import { vi, beforeEach, afterEach } from 'vitest'
import { config } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
// ==================== 全局 Mock ====================
/**
* Mock localStorage
* 用于测试主题持久化等功能
*/
export function mockLocalStorage() {
const store = {}
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => { store[key] = value }),
removeItem: vi.fn((key) => { delete store[key] }),
clear: vi.fn(() => { Object.keys(store).forEach(key => delete store[key]) }),
get length() { return Object.keys(store).length },
key: vi.fn((index) => Object.keys(store)[index] || null),
_store: store // 用于测试时检查存储内容
}
}
/**
* Mock sessionStorage
*/
export function mockSessionStorage() {
const store = {}
return {
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => { store[key] = value }),
removeItem: vi.fn((key) => { delete store[key] }),
clear: vi.fn(() => { Object.keys(store).forEach(key => delete store[key]) }),
get length() { return Object.keys(store).length },
key: vi.fn((index) => Object.keys(store)[index] || null),
_store: store
}
}
/**
* Mock Router
*/
export function createMockRouter() {
return {
push: vi.fn(),
replace: vi.fn(),
go: vi.fn(),
back: vi.fn(),
forward: vi.fn(),
currentRoute: {
value: {
path: '/',
name: 'home',
params: {},
query: {}
}
}
}
}
/**
* Mock Axios
*/
export function createMockAxios() {
return {
get: vi.fn(),
post: vi.fn(),
put: vi.fn(),
patch: vi.fn(),
delete: vi.fn(),
create: vi.fn(() => createMockAxios()),
interceptors: {
request: { use: vi.fn(), eject: vi.fn() },
response: { use: vi.fn(), eject: vi.fn() }
},
defaults: {
headers: {
common: {}
}
}
}
}
// ==================== 全局配置 ====================
/**
* 配置 Vue Test Utils
*/
config.global.stubs = {
// 全局 stub 路由组件
RouterLink: true,
RouterView: true
}
/**
* 设置全局 Mock
*/
beforeEach(() => {
// 创建新的 Pinia 实例
setActivePinia(createPinia())
// Mock localStorage
const localStorageMock = mockLocalStorage()
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true
})
// Mock sessionStorage
const sessionStorageMock = mockSessionStorage()
Object.defineProperty(window, 'sessionStorage', {
value: sessionStorageMock,
writable: true
})
// Mock console.warn 和 console.error (可选,避免测试输出噪音)
vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
// 清理所有 Mock
vi.clearAllMocks()
vi.restoreAllMocks()
})
// ==================== 测试辅助函数 ====================
/**
* 等待 DOM 更新
* @param {number} ms - 等待毫秒数
*/
export function wait(ms = 0) {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* 等待下一个 tick
*/
export async function nextTick() {
await wait(0)
}
/**
* 创建测试用的 Pinia 实例
*/
export function createTestPinia(options = {}) {
return createPinia()
}
/**
* Mock fetch API
*/
export function mockFetch(response) {
return vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(response),
text: () => Promise.resolve(JSON.stringify(response)),
blob: () => Promise.resolve(new Blob()),
arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)),
headers: new Headers()
})
)
}
/**
* 创建 Mock DOM 元素
*/
export function createMockElement(tagName = 'div', options = {}) {
const element = document.createElement(tagName)
if (options.id) element.id = options.id
if (options.className) element.className = options.className
if (options.innerHTML) element.innerHTML = options.innerHTML
if (options.style) Object.assign(element.style, options.style)
return element
}
/**
* 模拟 ResizeObserver
*/
export class MockResizeObserver {
constructor(callback) {
this.callback = callback
this.observations = []
}
observe(target) {
this.observations.push(target)
}
unobserve(target) {
this.observations = this.observations.filter(t => t !== target)
}
disconnect() {
this.observations = []
}
// 手动触发回调
trigger(entries) {
this.callback(entries, this)
}
}
// 全局 Mock ResizeObserver
if (typeof window !== 'undefined') {
window.ResizeObserver = MockResizeObserver
}
/**
* 模拟 IntersectionObserver
*/
export class MockIntersectionObserver {
constructor(callback) {
this.callback = callback
this.observations = []
}
observe(target) {
this.observations.push(target)
}
unobserve(target) {
this.observations = this.observations.filter(t => t !== target)
}
disconnect() {
this.observations = []
}
trigger(entries) {
this.callback(entries, this)
}
}
if (typeof window !== 'undefined') {
window.IntersectionObserver = MockIntersectionObserver
}
/**
* Mock WebGL Context
* 用于测试使用 Three.js 的组件
*/
export function mockWebGL() {
const mockContext = {
getExtension: vi.fn(() => null),
// WebGL 常量 - 必须在 getParameter 之前定义
VERSION: 7938,
VENDOR: 7936,
RENDERER: 7937,
SHADING_LANGUAGE_VERSION: 35724,
getParameter: vi.fn(function(param) {
// 返回一些常见参数的默认值
switch (param) {
case 7938: return 'WebGL 2.0 (Mock)' // VERSION
case 7936: return 'Mock Vendor' // VENDOR
case 7937: return 'Mock Renderer' // RENDERER
case 35724: return 'WebGL GLSL ES 3.0' // SHADING_LANGUAGE_VERSION
case 3379: return 4096 // MAX_TEXTURE_SIZE
case 34076: return 4096 // MAX_CUBE_MAP_TEXTURE_SIZE
case 34024: return 4096 // MAX_RENDERBUFFER_SIZE
case 34921: return 16 // MAX_VERTEX_ATTRIBS
case 36347: return 256 // MAX_VERTEX_UNIFORM_VECTORS
case 36348: return 8 // MAX_VARYING_VECTORS
case 36349: return 256 // MAX_FRAGMENT_UNIFORM_VECTORS
case 34930: return 16 // MAX_TEXTURE_IMAGE_UNITS
case 35660: return 4 // MAX_VERTEX_TEXTURE_IMAGE_UNITS
case 35661: return 20 // MAX_COMBINED_TEXTURE_IMAGE_UNITS
case 35071: return 256 // MAX_3D_TEXTURE_SIZE (WebGL2)
case 35657: return 8 // MAX_DRAW_BUFFERS (WebGL2)
case 35658: return 8 // MAX_COLOR_ATTACHMENTS (WebGL2)
default: return 0
}
}),
createShader: vi.fn(() => ({})),
shaderSource: vi.fn(),
compileShader: vi.fn(),
getShaderParameter: vi.fn(() => true),
getShaderInfoLog: vi.fn(() => ''),
createProgram: vi.fn(() => ({})),
attachShader: vi.fn(),
linkProgram: vi.fn(),
getProgramParameter: vi.fn(() => true),
getProgramInfoLog: vi.fn(() => ''),
useProgram: vi.fn(),
createBuffer: vi.fn(() => ({})),
bindBuffer: vi.fn(),
bufferData: vi.fn(),
enableVertexAttribArray: vi.fn(),
disableVertexAttribArray: vi.fn(),
vertexAttribPointer: vi.fn(),
getAttribLocation: vi.fn(() => 0),
getUniformLocation: vi.fn(() => ({})),
uniform1i: vi.fn(),
uniform1f: vi.fn(),
uniform2f: vi.fn(),
uniform2fv: vi.fn(),
uniform3f: vi.fn(),
uniform3fv: vi.fn(),
uniform4f: vi.fn(),
uniform4fv: vi.fn(),
uniformMatrix3fv: vi.fn(),
uniformMatrix4fv: vi.fn(),
createTexture: vi.fn(() => ({})),
bindTexture: vi.fn(),
texImage2D: vi.fn(),
texImage3D: vi.fn(),
texSubImage2D: vi.fn(),
texSubImage3D: vi.fn(),
texStorage2D: vi.fn(),
texStorage3D: vi.fn(),
texParameteri: vi.fn(),
texParameterf: vi.fn(),
activeTexture: vi.fn(),
generateMipmap: vi.fn(),
copyTexImage2D: vi.fn(),
copyTexSubImage2D: vi.fn(),
copyTexSubImage3D: vi.fn(),
compressedTexImage2D: vi.fn(),
compressedTexImage3D: vi.fn(),
compressedTexSubImage2D: vi.fn(),
compressedTexSubImage3D: vi.fn(),
viewport: vi.fn(),
scissor: vi.fn(),
clearColor: vi.fn(),
clearDepth: vi.fn(),
clearStencil: vi.fn(),
clear: vi.fn(),
drawArrays: vi.fn(),
drawElements: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
blendFunc: vi.fn(),
blendFuncSeparate: vi.fn(),
blendEquation: vi.fn(),
blendEquationSeparate: vi.fn(),
depthFunc: vi.fn(),
depthMask: vi.fn(),
depthRange: vi.fn(),
cullFace: vi.fn(),
frontFace: vi.fn(),
polygonOffset: vi.fn(),
pixelStorei: vi.fn(),
deleteShader: vi.fn(),
deleteProgram: vi.fn(),
deleteBuffer: vi.fn(),
deleteTexture: vi.fn(),
deleteFramebuffer: vi.fn(),
deleteRenderbuffer: vi.fn(),
createFramebuffer: vi.fn(() => ({})),
bindFramebuffer: vi.fn(),
framebufferTexture2D: vi.fn(),
framebufferTextureLayer: vi.fn(),
framebufferRenderbuffer: vi.fn(),
checkFramebufferStatus: vi.fn(() => 36053), // FRAMEBUFFER_COMPLETE
invalidateFramebuffer: vi.fn(),
invalidateSubFramebuffer: vi.fn(),
readBuffer: vi.fn(),
blitFramebuffer: vi.fn(),
createRenderbuffer: vi.fn(() => ({})),
bindRenderbuffer: vi.fn(),
renderbufferStorage: vi.fn(),
renderbufferStorageMultisample: vi.fn(),
isContextLost: vi.fn(() => false),
getContextAttributes: vi.fn(() => ({
alpha: true,
antialias: true,
depth: true,
failIfMajorPerformanceCaveat: false,
powerPreference: 'default',
premultipliedAlpha: true,
preserveDrawingBuffer: false,
stencil: false
})),
// Three.js 需要的额外方法
getShaderPrecisionFormat: vi.fn(() => ({
precision: 23,
rangeMin: 127,
rangeMax: 127
})),
getSupportedExtensions: vi.fn(() => []),
drawingBufferWidth: 800,
drawingBufferHeight: 600,
canvas: {
width: 800,
height: 600,
style: {},
addEventListener: vi.fn(),
removeEventListener: vi.fn()
},
// WebGL2 额外方法
createVertexArray: vi.fn(() => ({})),
bindVertexArray: vi.fn(),
deleteVertexArray: vi.fn(),
isVertexArray: vi.fn(() => true),
createSampler: vi.fn(() => ({})),
deleteSampler: vi.fn(),
bindSampler: vi.fn(),
samplerParameteri: vi.fn(),
samplerParameterf: vi.fn(),
createTransformFeedback: vi.fn(() => ({})),
deleteTransformFeedback: vi.fn(),
bindTransformFeedback: vi.fn(),
beginTransformFeedback: vi.fn(),
endTransformFeedback: vi.fn(),
transformFeedbackVaryings: vi.fn(),
getTransformFeedbackVarying: vi.fn(() => null),
pauseTransformFeedback: vi.fn(),
resumeTransformFeedback: vi.fn(),
createQuery: vi.fn(() => ({})),
deleteQuery: vi.fn(),
beginQuery: vi.fn(),
endQuery: vi.fn(),
getQuery: vi.fn(() => null),
getQueryParameter: vi.fn(() => 0),
fenceSync: vi.fn(() => ({})),
deleteSync: vi.fn(),
clientWaitSync: vi.fn(() => 37149), // ALREADY_SIGNALED
waitSync: vi.fn(),
getSyncParameter: vi.fn(() => 37145), // SIGNALED
getUniformBlockIndex: vi.fn(() => 0),
getActiveUniformBlockParameter: vi.fn(() => 0),
getActiveUniformBlockName: vi.fn(() => ''),
uniformBlockBinding: vi.fn(),
getActiveUniforms: vi.fn(() => []),
getUniformIndices: vi.fn(() => []),
uniform1ui: vi.fn(),
uniform2ui: vi.fn(),
uniform3ui: vi.fn(),
uniform4ui: vi.fn(),
uniform1uiv: vi.fn(),
uniform2uiv: vi.fn(),
uniform3uiv: vi.fn(),
uniform4uiv: vi.fn(),
uniformMatrix2x3fv: vi.fn(),
uniformMatrix3x2fv: vi.fn(),
uniformMatrix2x4fv: vi.fn(),
uniformMatrix4x2fv: vi.fn(),
uniformMatrix3x4fv: vi.fn(),
uniformMatrix4x3fv: vi.fn(),
vertexAttribI4i: vi.fn(),
vertexAttribI4ui: vi.fn(),
vertexAttribI4iv: vi.fn(),
vertexAttribI4uiv: vi.fn(),
vertexAttribIPointer: vi.fn(),
vertexAttribDivisor: vi.fn(),
drawArraysInstanced: vi.fn(),
drawElementsInstanced: vi.fn(),
drawRangeElements: vi.fn(),
drawBuffers: vi.fn(),
clearBufferfv: vi.fn(),
clearBufferiv: vi.fn(),
clearBufferuiv: vi.fn(),
clearBufferfi: vi.fn(),
getBufferSubData: vi.fn(),
bufferSubData: vi.fn(),
copyBufferSubData: vi.fn(),
getFragDataLocation: vi.fn(() => 0),
bindBufferBase: vi.fn(),
bindBufferRange: vi.fn(),
getIndexedParameter: vi.fn(() => null),
readPixels: vi.fn(),
// WebGL 常量
VERTEX_SHADER: 35633,
FRAGMENT_SHADER: 35632,
HIGH_FLOAT: 36338,
MEDIUM_FLOAT: 36337,
LOW_FLOAT: 36336,
HIGH_INT: 36341,
MEDIUM_INT: 36340,
LOW_INT: 36339,
COMPILE_STATUS: 35713,
LINK_STATUS: 35714,
ARRAY_BUFFER: 34962,
ELEMENT_ARRAY_BUFFER: 34963,
STATIC_DRAW: 35044,
DYNAMIC_DRAW: 35048,
FLOAT: 5126,
UNSIGNED_SHORT: 5123,
UNSIGNED_INT: 5125,
TRIANGLES: 4,
TEXTURE_2D: 3553,
TEXTURE0: 33984,
RGBA: 6408,
UNSIGNED_BYTE: 5121,
TEXTURE_MAG_FILTER: 10240,
TEXTURE_MIN_FILTER: 10241,
TEXTURE_WRAP_S: 10242,
TEXTURE_WRAP_T: 10243,
LINEAR: 9729,
NEAREST: 9728,
CLAMP_TO_EDGE: 33071,
REPEAT: 10497,
FRAMEBUFFER: 36160,
RENDERBUFFER: 36161,
DEPTH_COMPONENT16: 33189,
COLOR_ATTACHMENT0: 36064,
DEPTH_ATTACHMENT: 36096,
FRAMEBUFFER_COMPLETE: 36053,
DEPTH_TEST: 2929,
BLEND: 3042,
CULL_FACE: 2884,
SRC_ALPHA: 770,
ONE_MINUS_SRC_ALPHA: 771,
LEQUAL: 515,
BACK: 1029,
CCW: 2305,
UNPACK_FLIP_Y_WEBGL: 37440,
UNPACK_PREMULTIPLY_ALPHA_WEBGL: 37441,
// WebGL2 常量
TEXTURE_3D: 32879,
TEXTURE_2D_ARRAY: 35866,
TEXTURE_WRAP_R: 32882,
READ_FRAMEBUFFER: 36008,
DRAW_FRAMEBUFFER: 36009,
COPY_READ_BUFFER: 36662,
COPY_WRITE_BUFFER: 36663,
UNIFORM_BUFFER: 35345,
TRANSFORM_FEEDBACK_BUFFER: 35982,
PIXEL_PACK_BUFFER: 35051,
PIXEL_UNPACK_BUFFER: 35052,
TRANSFORM_FEEDBACK: 36386,
QUERY_RESULT: 34918,
QUERY_RESULT_AVAILABLE: 34919,
ANY_SAMPLES_PASSED: 35887,
SYNC_GPU_COMMANDS_COMPLETE: 37143,
ALREADY_SIGNALED: 37149,
TIMEOUT_EXPIRED: 37147,
CONDITION_SATISFIED: 37148,
WAIT_FAILED: 37149,
SIGNALED: 37145,
UNSIGNALED: 37144
}
return mockContext
}
// 全局 Mock HTMLCanvasElement.getContext
if (typeof HTMLCanvasElement !== 'undefined') {
const originalGetContext = HTMLCanvasElement.prototype.getContext
HTMLCanvasElement.prototype.getContext = function(type, ...args) {
if (type === 'webgl' || type === 'webgl2' || type === 'experimental-webgl') {
return mockWebGL()
}
return originalGetContext.call(this, type, ...args)
}
}
// Mock URL.createObjectURL 和 URL.revokeObjectURL
if (typeof URL !== 'undefined') {
URL.createObjectURL = vi.fn(() => 'blob:mock-url')
URL.revokeObjectURL = vi.fn()
}

@ -0,0 +1,4 @@
/**
* 单元测试包
* 测试独立的函数和模块
*/

@ -0,0 +1,204 @@
/**
* 认证 API 模块单元测试
* 测试 src/api/auth.js 的基本功能
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
authLogin,
authRegister,
authLogout,
authGetProfile,
sendAuthCode,
authForgotPassword,
getVipStatus
} from '@/api/auth'
import { UserFactory } from '../../factories'
// Mock request 模块
vi.mock('@/utils/request', () => ({
default: vi.fn()
}))
import request from '@/utils/request'
describe('Auth API 模块', () => {
beforeEach(() => {
vi.clearAllMocks()
UserFactory.reset()
})
// ==================== authLogin 测试 ====================
describe('authLogin', () => {
it('应发送正确的登录请求', async () => {
const loginResponse = UserFactory.createLoginResponse()
request.mockResolvedValue(loginResponse)
const credentials = { username: 'testuser', password: 'password123' }
const result = await authLogin(credentials)
expect(request).toHaveBeenCalledWith({
url: '/auth/login',
method: 'post',
data: credentials
})
expect(result).toEqual(loginResponse)
})
it('登录失败应抛出错误', async () => {
request.mockRejectedValue(new Error('用户名或密码错误'))
await expect(authLogin({ username: 'test', password: 'wrong' }))
.rejects.toThrow('用户名或密码错误')
})
})
// ==================== authRegister 测试 ====================
describe('authRegister', () => {
it('应发送正确的注册请求', async () => {
const registerResponse = { message: '注册成功', user: UserFactory.create() }
request.mockResolvedValue(registerResponse)
const registerData = {
username: 'newuser',
password: 'password123',
email: 'new@example.com',
code: '123456'
}
const result = await authRegister(registerData)
expect(request).toHaveBeenCalledWith({
url: '/auth/register',
method: 'post',
data: registerData
})
expect(result).toEqual(registerResponse)
})
it('应支持 VIP 邀请码', async () => {
request.mockResolvedValue({ message: '注册成功' })
const registerData = {
username: 'vipuser',
password: 'password123',
email: 'vip@example.com',
code: '123456',
vip_code: 'VIP2024'
}
await authRegister(registerData)
expect(request).toHaveBeenCalledWith({
url: '/auth/register',
method: 'post',
data: expect.objectContaining({ vip_code: 'VIP2024' })
})
})
})
// ==================== authLogout 测试 ====================
describe('authLogout', () => {
it('应发送正确的登出请求', async () => {
request.mockResolvedValue({ message: '登出成功' })
const result = await authLogout()
expect(request).toHaveBeenCalledWith({
url: '/auth/logout',
method: 'post',
data: {}
})
})
})
// ==================== authGetProfile 测试 ====================
describe('authGetProfile', () => {
it('应发送正确的获取用户信息请求', async () => {
const user = UserFactory.create()
request.mockResolvedValue({ user })
const result = await authGetProfile()
expect(request).toHaveBeenCalledWith({
url: '/auth/profile',
method: 'post',
data: {}
})
expect(result.user).toEqual(user)
})
})
// ==================== sendAuthCode 测试 ====================
describe('sendAuthCode', () => {
it('应发送注册验证码请求', async () => {
request.mockResolvedValue({ message: '验证码已发送' })
const codeData = { email: 'test@example.com', purpose: 'register' }
await sendAuthCode(codeData)
expect(request).toHaveBeenCalledWith({
url: '/auth/code',
method: 'post',
data: codeData
})
})
it('应发送修改邮箱验证码请求', async () => {
request.mockResolvedValue({ message: '验证码已发送' })
const codeData = { email: 'new@example.com', purpose: 'change_email' }
await sendAuthCode(codeData)
expect(request).toHaveBeenCalledWith({
url: '/auth/code',
method: 'post',
data: expect.objectContaining({ purpose: 'change_email' })
})
})
})
// ==================== authForgotPassword 测试 ====================
describe('authForgotPassword', () => {
it('应发送正确的重置密码请求', async () => {
request.mockResolvedValue({ message: '密码重置成功' })
const resetData = {
email: 'test@example.com',
code: '123456',
new_password: 'newpassword123'
}
await authForgotPassword(resetData)
expect(request).toHaveBeenCalledWith({
url: '/auth/forgot-password',
method: 'post',
data: resetData
})
})
})
// ==================== getVipStatus 测试 ====================
describe('getVipStatus', () => {
it('应发送正确的获取 VIP 状态请求', async () => {
request.mockResolvedValue({ is_vip: true, vip_expires_at: '2025-12-31' })
const result = await getVipStatus()
expect(request).toHaveBeenCalledWith({
url: '/auth/vip-status',
method: 'get'
})
})
})
})

@ -0,0 +1,389 @@
/**
* 任务 API 模块单元测试
* 测试 src/api/task.js 的基本功能
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import {
getTaskQuota,
getTaskList,
submitPerturbationTask,
startPerturbationTask,
getTaskStatus,
getStylePresets,
getTaskLogs,
cancelTask,
submitFinetuneFromPerturbation,
submitFinetuneFromUpload,
startFinetuneTask,
submitEvaluateTask,
startEvaluateTask,
submitHeatmapTask,
startHeatmapTask,
getTaskDetail,
listPerturbationTasks,
updatePerturbationTask,
getFinetuneCoords,
restartTask,
deleteTask
} from '@/api/task'
import { TaskFactory } from '../../factories'
// Mock request 模块
vi.mock('@/utils/request', () => ({
default: vi.fn()
}))
import request from '@/utils/request'
describe('Task API 模块', () => {
beforeEach(() => {
vi.clearAllMocks()
TaskFactory.reset()
})
// ==================== 配额相关测试 ====================
describe('getTaskQuota', () => {
it('应发送正确的获取配额请求', async () => {
const quota = TaskFactory.createQuota()
request.mockResolvedValue(quota)
const result = await getTaskQuota()
expect(request).toHaveBeenCalledWith({
url: '/task/quota',
method: 'get'
})
expect(result).toEqual(quota)
})
})
// ==================== 任务列表测试 ====================
describe('getTaskList', () => {
it('应发送正确的获取任务列表请求', async () => {
const tasks = TaskFactory.createList(3)
request.mockResolvedValue({ tasks })
const result = await getTaskList({ task_status: 'all' })
expect(request).toHaveBeenCalledWith({
url: '/task',
method: 'get',
params: { task_status: 'all' }
})
expect(result.tasks).toHaveLength(3)
})
it('应支持按类型筛选', async () => {
request.mockResolvedValue({ tasks: [] })
await getTaskList({ task_type: 'perturbation' })
expect(request).toHaveBeenCalledWith({
url: '/task',
method: 'get',
params: { task_type: 'perturbation' }
})
})
it('应支持按状态筛选', async () => {
request.mockResolvedValue({ tasks: [] })
await getTaskList({ task_status: 'completed' })
expect(request).toHaveBeenCalledWith({
url: '/task',
method: 'get',
params: { task_status: 'completed' }
})
})
})
// ==================== 加噪任务测试 ====================
describe('submitPerturbationTask', () => {
it('应发送正确的提交加噪任务请求', async () => {
request.mockResolvedValue({ task_id: 1, message: '任务创建成功' })
const formData = new FormData()
formData.append('data_type_id', '1')
formData.append('perturbation_configs_id', '1')
await submitPerturbationTask(formData)
expect(request).toHaveBeenCalledWith({
url: '/task/perturbation',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
})
})
describe('startPerturbationTask', () => {
it('应发送正确的启动加噪任务请求', async () => {
request.mockResolvedValue({ message: '任务已启动' })
await startPerturbationTask(123)
expect(request).toHaveBeenCalledWith({
url: '/task/perturbation/123/start',
method: 'post'
})
})
})
describe('getStylePresets', () => {
it('应发送正确的获取风格预设请求', async () => {
request.mockResolvedValue({ presets: [] })
await getStylePresets()
expect(request).toHaveBeenCalledWith({
url: '/task/perturbation/style-presets',
method: 'get'
})
})
})
// ==================== 任务状态测试 ====================
describe('getTaskStatus', () => {
it('应发送正确的获取任务状态请求', async () => {
request.mockResolvedValue({ status: 'processing', progress: 50 })
await getTaskStatus(123)
expect(request).toHaveBeenCalledWith({
url: '/task/123/status',
method: 'get'
})
})
})
describe('getTaskDetail', () => {
it('应发送正确的获取任务详情请求', async () => {
const task = TaskFactory.createPerturbation()
request.mockResolvedValue({ task })
await getTaskDetail(123)
expect(request).toHaveBeenCalledWith({
url: '/task/123',
method: 'get'
})
})
})
// ==================== 任务结果测试 ====================
describe('getTaskLogs', () => {
it('应发送正确的获取任务日志请求', async () => {
request.mockResolvedValue({ logs: [] })
await getTaskLogs(123)
expect(request).toHaveBeenCalledWith({
url: '/task/123/logs',
method: 'get'
})
})
})
// ==================== 任务操作测试 ====================
describe('cancelTask', () => {
it('应发送正确的取消任务请求', async () => {
request.mockResolvedValue({ message: '任务已取消' })
await cancelTask(123)
expect(request).toHaveBeenCalledWith({
url: '/task/123/cancel',
method: 'post'
})
})
})
describe('restartTask', () => {
it('应发送正确的重启任务请求', async () => {
request.mockResolvedValue({ message: '任务已重启' })
await restartTask(123)
expect(request).toHaveBeenCalledWith({
url: '/task/123/restart',
method: 'post'
})
})
})
describe('deleteTask', () => {
it('应发送正确的删除任务请求', async () => {
request.mockResolvedValue({ message: '任务已删除' })
await deleteTask(123)
expect(request).toHaveBeenCalledWith({
url: '/task/123',
method: 'delete'
})
})
})
// ==================== 微调任务测试 ====================
describe('submitFinetuneFromPerturbation', () => {
it('应发送正确的基于加噪创建微调请求', async () => {
request.mockResolvedValue({ task_id: 2 })
const data = { perturbation_task_id: 1, finetune_configs_id: 1 }
await submitFinetuneFromPerturbation(data)
expect(request).toHaveBeenCalledWith({
url: '/task/finetune/from-perturbation',
method: 'post',
data
})
})
})
describe('submitFinetuneFromUpload', () => {
it('应发送正确的上传微调请求', async () => {
request.mockResolvedValue({ task_id: 3 })
const formData = new FormData()
await submitFinetuneFromUpload(formData)
expect(request).toHaveBeenCalledWith({
url: '/task/finetune/from-upload',
method: 'post',
data: formData,
headers: { 'Content-Type': 'multipart/form-data' }
})
})
})
describe('startFinetuneTask', () => {
it('应发送正确的启动微调任务请求', async () => {
request.mockResolvedValue({ message: '任务已启动' })
await startFinetuneTask(123)
expect(request).toHaveBeenCalledWith({
url: '/task/finetune/123/start',
method: 'post'
})
})
})
describe('getFinetuneCoords', () => {
it('应发送正确的获取 3D 坐标请求', async () => {
request.mockResolvedValue({ coords: [] })
await getFinetuneCoords(123)
expect(request).toHaveBeenCalledWith({
url: '/task/finetune/123/coords',
method: 'get'
})
})
})
// ==================== 评估任务测试 ====================
describe('submitEvaluateTask', () => {
it('应发送正确的创建评估任务请求', async () => {
request.mockResolvedValue({ task_id: 4 })
const data = { finetune_task_id: 2 }
await submitEvaluateTask(data)
expect(request).toHaveBeenCalledWith({
url: '/task/evaluate',
method: 'post',
data
})
})
})
describe('startEvaluateTask', () => {
it('应发送正确的启动评估任务请求', async () => {
request.mockResolvedValue({ message: '任务已启动' })
await startEvaluateTask(123)
expect(request).toHaveBeenCalledWith({
url: '/task/evaluate/123/start',
method: 'post'
})
})
})
// ==================== 热力图任务测试 ====================
describe('submitHeatmapTask', () => {
it('应发送正确的创建热力图任务请求', async () => {
request.mockResolvedValue({ task_id: 5 })
const data = { perturbation_task_id: 1, perturbed_image_id: 10 }
await submitHeatmapTask(data)
expect(request).toHaveBeenCalledWith({
url: '/task/heatmap',
method: 'post',
data
})
})
})
describe('startHeatmapTask', () => {
it('应发送正确的启动热力图任务请求', async () => {
request.mockResolvedValue({ message: '任务已启动' })
await startHeatmapTask(123)
expect(request).toHaveBeenCalledWith({
url: '/task/heatmap/123/start',
method: 'post'
})
})
})
// ==================== 其他测试 ====================
describe('listPerturbationTasks', () => {
it('应发送正确的获取加噪任务列表请求', async () => {
request.mockResolvedValue({ tasks: [] })
await listPerturbationTasks()
expect(request).toHaveBeenCalledWith({
url: '/task/perturbation',
method: 'get'
})
})
})
describe('updatePerturbationTask', () => {
it('应发送正确的更新加噪任务请求', async () => {
request.mockResolvedValue({ message: '更新成功' })
const data = { description: '新描述' }
await updatePerturbationTask(123, data)
expect(request).toHaveBeenCalledWith({
url: '/task/perturbation/123',
method: 'patch',
data
})
})
})
})

@ -0,0 +1,244 @@
/**
* 导航栏高亮位置计算属性测试 (Property-Based Testing)
* 使用 fast-check 库进行属性测试
*/
import { describe, it, expect } from 'vitest'
import * as fc from 'fast-check'
import {
calculateItemHeightPercent,
calculateHighlightTop,
calculateHighlightHeight,
isHighlightWithinBounds
} from '@/utils/navbarHighlight'
describe('NavbarHighlight 属性测试', () => {
// ==================== Property 1: 高亮位置始终在有效范围内 ====================
/**
* Property 1: 高亮位置始终在有效范围内
* *For any* valid activeIndex and navItemsCount, the highlight should be within bounds (10% to 90%)
* Validates: Requirements 2.6
*/
it('属性:高亮位置始终在有效范围内', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 20 }), // navItemsCount
(navItemsCount) => {
// 测试所有有效的 activeIndex
for (let activeIndex = 0; activeIndex < navItemsCount; activeIndex++) {
const top = calculateHighlightTop(activeIndex, navItemsCount)
const height = calculateHighlightHeight(navItemsCount)
if (!isHighlightWithinBounds(top, height)) {
return false
}
}
return true
}
),
{ numRuns: 100 }
)
})
// ==================== Property 2: 高亮位置随索引单调递增 ====================
/**
* Property 2: 高亮位置随索引单调递增
* *For any* navItemsCount, highlight top should increase as activeIndex increases
* Validates: Requirements 2.6
*/
it('属性:高亮位置随索引单调递增', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 20 }), // navItemsCount (至少 2 个才能比较)
(navItemsCount) => {
let previousTop = -Infinity
for (let i = 0; i < navItemsCount; i++) {
const top = calculateHighlightTop(i, navItemsCount)
if (top <= previousTop) {
return false
}
previousTop = top
}
return true
}
),
{ numRuns: 100 }
)
})
// ==================== Property 3: 所有项高度之和等于 80% ====================
/**
* Property 3: 所有项高度之和等于 80%
* *For any* navItemsCount, the sum of all item heights should equal 80%
* Validates: Requirements 2.6
*/
it('属性:所有项高度之和等于 80%', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 100 }),
(navItemsCount) => {
const itemHeight = calculateItemHeightPercent(navItemsCount)
const totalHeight = itemHeight * navItemsCount
// 允许浮点数误差
return Math.abs(totalHeight - 80) < 0.0001
}
),
{ numRuns: 100 }
)
})
// ==================== Property 4: 第一项 top 始终为 10% ====================
/**
* Property 4: 第一项 top 始终为 10%
* *For any* navItemsCount, the first item's top should always be 10%
* Validates: Requirements 2.6
*/
it('属性:第一项 top 始终为 10%', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 100 }),
(navItemsCount) => {
const top = calculateHighlightTop(0, navItemsCount)
return top === 10
}
),
{ numRuns: 100 }
)
})
// ==================== Property 5: 最后一项底部不超过 90% ====================
/**
* Property 5: 最后一项底部不超过 90%
* *For any* navItemsCount, the last item's bottom should not exceed 90%
* Validates: Requirements 2.6
*/
it('属性:最后一项底部不超过 90%', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 100 }),
(navItemsCount) => {
const lastIndex = navItemsCount - 1
const top = calculateHighlightTop(lastIndex, navItemsCount)
const height = calculateHighlightHeight(navItemsCount)
const bottom = top + height
// 允许浮点数误差
return bottom <= 90 + 0.0001
}
),
{ numRuns: 100 }
)
})
// ==================== Property 6: 相邻项之间无间隙 ====================
/**
* Property 6: 相邻项之间无间隙
* *For any* navItemsCount, adjacent items should have no gap between them
* Validates: Requirements 2.6
*/
it('属性:相邻项之间无间隙', () => {
fc.assert(
fc.property(
fc.integer({ min: 2, max: 20 }),
(navItemsCount) => {
const height = calculateHighlightHeight(navItemsCount)
for (let i = 0; i < navItemsCount - 1; i++) {
const currentTop = calculateHighlightTop(i, navItemsCount)
const nextTop = calculateHighlightTop(i + 1, navItemsCount)
const currentBottom = currentTop + height
// 当前项底部应等于下一项顶部
if (Math.abs(currentBottom - nextTop) > 0.0001) {
return false
}
}
return true
}
),
{ numRuns: 100 }
)
})
// ==================== Property 7: 高度计算一致性 ====================
/**
* Property 7: 高度计算一致性
* *For any* navItemsCount, calculateHighlightHeight should equal calculateItemHeightPercent
* Validates: Requirements 2.6
*/
it('属性:高度计算一致性', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 100 }),
(navItemsCount) => {
return calculateHighlightHeight(navItemsCount) === calculateItemHeightPercent(navItemsCount)
}
),
{ numRuns: 100 }
)
})
// ==================== Property 8: 无效输入应抛出错误 ====================
/**
* Property 8: 无效输入应抛出错误
* *For any* non-positive navItemsCount, functions should throw an error
* Validates: Requirements 2.6
*/
it('属性:无效输入应抛出错误', () => {
fc.assert(
fc.property(
fc.integer({ min: -100, max: 0 }),
(invalidCount) => {
let threw = false
try {
calculateItemHeightPercent(invalidCount)
} catch (e) {
threw = true
}
return threw
}
),
{ numRuns: 100 }
)
})
// ==================== Property 9: 索引越界应抛出错误 ====================
/**
* Property 9: 索引越界应抛出错误
* *For any* activeIndex >= navItemsCount, calculateHighlightTop should throw an error
* Validates: Requirements 2.6
*/
it('属性:索引越界应抛出错误', () => {
fc.assert(
fc.property(
fc.integer({ min: 1, max: 20 }),
fc.integer({ min: 0, max: 100 }),
(navItemsCount, offset) => {
const invalidIndex = navItemsCount + offset
let threw = false
try {
calculateHighlightTop(invalidIndex, navItemsCount)
} catch (e) {
threw = true
}
return threw
}
),
{ numRuns: 100 }
)
})
})

@ -0,0 +1,325 @@
/**
* 状态管理属性测试 (Property-Based Testing)
* 使用 fast-check 库进行属性测试
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import * as fc from 'fast-check'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/userStore'
import { useTaskStore } from '@/stores/taskStore'
// Mock API 模块
vi.mock('@/api/task', () => ({
getTaskList: vi.fn().mockResolvedValue({ tasks: [] }),
getTaskQuota: vi.fn().mockResolvedValue({ max_tasks: 5, current_tasks: 0, remaining_tasks: 5 })
}))
describe('Stores 属性测试', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
// 设置 localStorage mock 行为
let store = {}
localStorage.setItem.mockImplementation((key, value) => {
store[key] = value
})
localStorage.getItem.mockImplementation((key) => store[key] || null)
localStorage.removeItem.mockImplementation((key) => {
delete store[key]
})
localStorage.clear.mockImplementation(() => {
store = {}
})
})
// ==================== UserStore 属性测试 ====================
describe('UserStore 属性测试', () => {
/**
* Property 1: 登录数据往返一致性
* *For any* valid login data, setLoginData then reading should return the same data
* Validates: Requirements 1.1
*/
it('属性:登录数据往返一致性', () => {
fc.assert(
fc.property(
fc.record({
access_token: fc.string({ minLength: 1 }),
user: fc.record({
user_id: fc.integer({ min: 1 }),
username: fc.string({ minLength: 1, maxLength: 50 }).filter(s => s.trim().length > 0),
email: fc.emailAddress(),
role: fc.constantFrom('admin', 'vip', 'user'),
is_active: fc.boolean()
})
}),
(loginData) => {
const store = useUserStore()
store.setLoginData(loginData)
return store.token === loginData.access_token &&
store.userInfo.user_id === loginData.user.user_id &&
store.userInfo.username === loginData.user.username
}
),
{ numRuns: 100 }
)
})
/**
* Property 2: 登录后 isLoggedIn 始终为 true
* *For any* non-empty token, isLoggedIn should be true
* Validates: Requirements 1.1
*/
it('属性:登录后 isLoggedIn 始终为 true', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1 }),
(token) => {
const store = useUserStore()
store.token = token
return store.isLoggedIn === true
}
),
{ numRuns: 100 }
)
})
/**
* Property 3: 登出后状态完全清除
* *For any* login state, logout should clear all state
* Validates: Requirements 1.2
*/
it('属性:登出后状态完全清除', () => {
fc.assert(
fc.property(
fc.record({
access_token: fc.string({ minLength: 1 }),
user: fc.record({
username: fc.string({ minLength: 1 }),
role: fc.constantFrom('admin', 'vip', 'user')
})
}),
(loginData) => {
const store = useUserStore()
// 先登录
store.setLoginData(loginData)
// 再登出
store.logout()
return store.token === '' &&
store.userInfo === null &&
store.isLoggedIn === false
}
),
{ numRuns: 100 }
)
})
/**
* Property 4: VIP 判断一致性
* *For any* user with admin or vip role, isVip should be true
* Validates: Requirements 1.3
*/
it('属性VIP 判断一致性', () => {
fc.assert(
fc.property(
fc.constantFrom('admin', 'vip', 'user'),
(role) => {
const store = useUserStore()
store.userInfo = { role }
const expectedVip = role === 'admin' || role === 'vip'
return store.isVip === expectedVip
}
),
{ numRuns: 100 }
)
})
/**
* Property 5: 用户名首字母提取正确性
* *For any* non-empty username, initials should be the first character uppercased
* Validates: Requirements 1.4
*/
it('属性:用户名首字母提取正确性', () => {
fc.assert(
fc.property(
fc.string({ minLength: 1, maxLength: 50 }).filter(s => /^[a-zA-Z]/.test(s)),
(username) => {
const store = useUserStore()
store.userInfo = { username }
return store.initials === username[0].toUpperCase()
}
),
{ numRuns: 100 }
)
})
/**
* Property 6: 更新用户信息保留原有字段
* *For any* user info update, unmodified fields should be preserved
* Validates: Requirements 1.5
*/
it('属性:更新用户信息保留原有字段', () => {
fc.assert(
fc.property(
fc.record({
username: fc.string({ minLength: 1 }),
email: fc.emailAddress(),
role: fc.constantFrom('admin', 'vip', 'user')
}),
fc.record({
role: fc.constantFrom('admin', 'vip', 'user')
}),
(originalInfo, updateInfo) => {
const store = useUserStore()
store.userInfo = { ...originalInfo }
store.updateUserInfo(updateInfo)
// 未更新的字段应保持不变
return store.userInfo.username === originalInfo.username &&
store.userInfo.email === originalInfo.email &&
store.userInfo.role === updateInfo.role
}
),
{ numRuns: 100 }
)
})
})
// ==================== TaskStore 属性测试 ====================
describe('TaskStore 属性测试', () => {
/**
* Property 7: 任务列表排序一致性
* *For any* task list, tasks should be sorted by created_at in descending order
* Validates: Requirements 2.1
*/
it('属性:任务列表排序一致性', () => {
fc.assert(
fc.property(
fc.array(
fc.record({
task_id: fc.integer({ min: 1 }),
task_type: fc.constantFrom('perturbation', 'finetune', 'heatmap', 'evaluate'),
status: fc.constantFrom('waiting', 'processing', 'completed', 'failed'),
description: fc.string(),
created_at: fc.date({ min: new Date('2020-01-01'), max: new Date('2025-12-31') })
.map(d => d.toISOString())
}),
{ minLength: 2, maxLength: 20 }
),
(tasks) => {
const store = useTaskStore()
// 模拟 fetchTasks 的排序逻辑
store.tasks = tasks.sort((a, b) =>
new Date(b.created_at) - new Date(a.created_at)
)
// 验证排序
for (let i = 0; i < store.tasks.length - 1; i++) {
const current = new Date(store.tasks[i].created_at)
const next = new Date(store.tasks[i + 1].created_at)
if (current < next) {
return false
}
}
return true
}
),
{ numRuns: 100 }
)
})
/**
* Property 8: sidebarTasks 最多返回 10
* *For any* task list, sidebarTasks should have at most 10 items
* Validates: Requirements 2.2
*/
it('属性sidebarTasks 最多返回 10 条', () => {
fc.assert(
fc.property(
fc.integer({ min: 0, max: 50 }),
(taskCount) => {
const store = useTaskStore()
// 创建任务列表
store.tasks = Array.from({ length: taskCount }, (_, i) => ({
task_id: i + 1,
task_type: 'perturbation',
status: 'waiting',
description: `Task ${i + 1}`,
created_at: new Date().toISOString()
}))
return store.sidebarTasks.length <= 10
}
),
{ numRuns: 100 }
)
})
/**
* Property 9: 任务进度映射正确性
* *For any* task status, progress should be correctly mapped
* Validates: Requirements 2.3
*/
it('属性:任务进度映射正确性', () => {
fc.assert(
fc.property(
fc.constantFrom('waiting', 'processing', 'completed', 'failed', 'pending'),
(status) => {
const store = useTaskStore()
store.tasks = [{
task_id: 1,
task_type: 'perturbation',
status,
description: 'Test',
created_at: new Date().toISOString()
}]
const sidebarTask = store.sidebarTasks[0]
// 验证进度映射
if (status === 'completed') {
return sidebarTask.progress === 100
} else if (status === 'processing') {
return sidebarTask.progress === 50
} else {
return sidebarTask.progress === 0
}
}
),
{ numRuns: 100 }
)
})
/**
* Property 10: pending 状态映射为 waiting
* *For any* task with pending status, it should be mapped to waiting
* Validates: Requirements 2.4
*/
it('属性pending 状态映射为 waiting', () => {
const store = useTaskStore()
store.tasks = [{
task_id: 1,
task_type: 'perturbation',
status: 'pending',
description: 'Test',
created_at: new Date().toISOString()
}]
expect(store.sidebarTasks[0].status).toBe('waiting')
})
})
})

@ -0,0 +1,231 @@
/**
* 主题工具函数属性测试 (Property-Based Testing)
* 使用 fast-check 库进行属性测试
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import * as fc from 'fast-check'
import {
VALID_THEMES,
DEFAULT_THEME,
isValidTheme,
normalizeTheme,
saveThemePreference,
loadThemePreference,
themeRoundTrip
} from '@/utils/theme'
describe('Theme 属性测试', () => {
beforeEach(() => {
localStorage.clear()
// 设置 localStorage mock 行为
let store = {}
localStorage.setItem.mockImplementation((key, value) => {
store[key] = value
})
localStorage.getItem.mockImplementation((key) => store[key] || null)
localStorage.removeItem.mockImplementation((key) => {
delete store[key]
})
localStorage.clear.mockImplementation(() => {
store = {}
})
})
// ==================== Property 1: 有效主题验证 ====================
/**
* Property 1: 有效主题验证一致性
* *For any* valid theme, isValidTheme should return true
* Validates: Requirements 3.1
*/
it('属性:有效主题验证一致性', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_THEMES),
(theme) => {
return isValidTheme(theme) === true
}
),
{ numRuns: 100 }
)
})
// ==================== Property 2: 无效主题验证 ====================
/**
* Property 2: 无效主题验证
* *For any* string that is not in VALID_THEMES, isValidTheme should return false
* Validates: Requirements 3.1
*/
it('属性:无效主题验证', () => {
fc.assert(
fc.property(
fc.string().filter(s => !VALID_THEMES.includes(s)),
(invalidTheme) => {
return isValidTheme(invalidTheme) === false
}
),
{ numRuns: 100 }
)
})
// ==================== Property 3: 主题规范化幂等性 ====================
/**
* Property 3: 主题规范化幂等性
* *For any* theme value, normalizing twice should equal normalizing once
* Validates: Requirements 3.1
*/
it('属性:主题规范化幂等性', () => {
fc.assert(
fc.property(
fc.string(),
(theme) => {
const once = normalizeTheme(theme)
const twice = normalizeTheme(normalizeTheme(theme))
return once === twice
}
),
{ numRuns: 100 }
)
})
// ==================== Property 4: 规范化结果始终有效 ====================
/**
* Property 4: 规范化结果始终有效
* *For any* input, normalizeTheme should always return a valid theme
* Validates: Requirements 3.1
*/
it('属性:规范化结果始终有效', () => {
fc.assert(
fc.property(
fc.oneof(
fc.string(),
fc.constant(null),
fc.constant(undefined),
fc.integer()
),
(input) => {
const result = normalizeTheme(input)
return VALID_THEMES.includes(result)
}
),
{ numRuns: 100 }
)
})
// ==================== Property 5: 有效主题规范化不变性 ====================
/**
* Property 5: 有效主题规范化不变性
* *For any* valid theme, normalizing should return the same theme
* Validates: Requirements 3.1
*/
it('属性:有效主题规范化不变性', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_THEMES),
(validTheme) => {
return normalizeTheme(validTheme) === validTheme
}
),
{ numRuns: 100 }
)
})
// ==================== Property 6: 主题持久化往返一致性 ====================
/**
* Property 6: 主题持久化往返一致性
* *For any* valid theme, saving then loading should return the same theme
* Validates: Requirements 3.5
*/
it('属性:主题持久化往返一致性', () => {
fc.assert(
fc.property(
fc.constantFrom(...VALID_THEMES),
(theme) => {
const result = themeRoundTrip(theme)
return result === theme
}
),
{ numRuns: 100 }
)
})
// ==================== Property 7: 无效主题往返后为默认主题 ====================
/**
* Property 7: 无效主题往返后为默认主题
* *For any* invalid theme, round-trip should return DEFAULT_THEME
* Validates: Requirements 3.5
*/
it('属性:无效主题往返后为默认主题', () => {
fc.assert(
fc.property(
fc.string().filter(s => !VALID_THEMES.includes(s)),
(invalidTheme) => {
const result = themeRoundTrip(invalidTheme)
return result === DEFAULT_THEME
}
),
{ numRuns: 100 }
)
})
// ==================== Property 8: 保存操作始终成功 ====================
/**
* Property 8: 保存操作始终成功
* *For any* theme value, saveThemePreference should return true (in normal conditions)
* Validates: Requirements 3.5
*/
it('属性:保存操作始终成功', () => {
fc.assert(
fc.property(
fc.string(),
(theme) => {
const result = saveThemePreference(theme)
return result === true
}
),
{ numRuns: 100 }
)
})
// ==================== Property 9: 加载操作始终返回有效主题 ====================
/**
* Property 9: 加载操作始终返回有效主题
* *For any* stored value, loadThemePreference should always return a valid theme
* Validates: Requirements 3.5
*/
it('属性:加载操作始终返回有效主题', () => {
fc.assert(
fc.property(
fc.oneof(
fc.constantFrom(...VALID_THEMES),
fc.string(),
fc.constant(null)
),
(storedValue) => {
// 设置存储值
if (storedValue !== null) {
localStorage._store = { theme: storedValue }
localStorage.getItem.mockReturnValue(storedValue)
} else {
localStorage._store = {}
localStorage.getItem.mockReturnValue(null)
}
const result = loadThemePreference()
return VALID_THEMES.includes(result)
}
),
{ numRuns: 100 }
)
})
})

@ -0,0 +1,253 @@
/**
* 任务状态管理单元测试
* 测试 src/stores/taskStore.js 的基本功能
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useTaskStore } from '@/stores/taskStore'
import { TaskFactory } from '../../factories'
// Mock API 模块
vi.mock('@/api/task', () => ({
getTaskList: vi.fn(),
getTaskQuota: vi.fn()
}))
import { getTaskList, getTaskQuota } from '@/api/task'
describe('TaskStore 状态管理', () => {
beforeEach(() => {
setActivePinia(createPinia())
TaskFactory.reset()
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
// ==================== 初始状态测试 ====================
describe('初始状态', () => {
it('初始状态应为空', () => {
const store = useTaskStore()
expect(store.tasks).toEqual([])
expect(store.quota.max_tasks).toBe(5)
expect(store.quota.current_tasks).toBe(0)
expect(store.quota.remaining_tasks).toBe(5)
expect(store.timer).toBeNull()
expect(store.isLoading).toBe(false)
})
})
// ==================== Actions 测试 ====================
describe('Actions', () => {
describe('fetchTasks', () => {
it('应获取并存储任务列表', async () => {
const store = useTaskStore()
const mockTasks = TaskFactory.createList(3)
getTaskList.mockResolvedValue({ tasks: mockTasks })
await store.fetchTasks()
expect(getTaskList).toHaveBeenCalledWith({ task_status: 'all' })
expect(store.tasks).toHaveLength(3)
})
it('应按创建时间倒序排列任务', async () => {
const store = useTaskStore()
const mockTasks = [
TaskFactory.createPerturbation({ created_at: '2024-01-01T00:00:00Z' }),
TaskFactory.createPerturbation({ created_at: '2024-01-03T00:00:00Z' }),
TaskFactory.createPerturbation({ created_at: '2024-01-02T00:00:00Z' })
]
getTaskList.mockResolvedValue({ tasks: mockTasks })
await store.fetchTasks()
expect(new Date(store.tasks[0].created_at).getTime())
.toBeGreaterThan(new Date(store.tasks[1].created_at).getTime())
})
it('API 错误时应保持原有状态', async () => {
const store = useTaskStore()
store.tasks = [TaskFactory.createPerturbation()]
getTaskList.mockRejectedValue(new Error('Network error'))
await store.fetchTasks()
expect(store.tasks).toHaveLength(1)
})
})
describe('fetchQuota', () => {
it('应获取并存储配额信息', async () => {
const store = useTaskStore()
const mockQuota = TaskFactory.createQuota({ max_tasks: 10, current_tasks: 3 })
getTaskQuota.mockResolvedValue(mockQuota)
await store.fetchQuota()
expect(store.quota.max_tasks).toBe(10)
expect(store.quota.current_tasks).toBe(3)
})
it('API 错误时应保持原有配额', async () => {
const store = useTaskStore()
store.quota = { max_tasks: 5, current_tasks: 2, remaining_tasks: 3 }
getTaskQuota.mockRejectedValue(new Error('Network error'))
await store.fetchQuota()
expect(store.quota.max_tasks).toBe(5)
})
})
describe('startPolling', () => {
it('应立即执行一次获取', async () => {
const store = useTaskStore()
getTaskList.mockResolvedValue({ tasks: [] })
getTaskQuota.mockResolvedValue(TaskFactory.createQuota())
await store.startPolling()
expect(getTaskList).toHaveBeenCalledTimes(1)
expect(getTaskQuota).toHaveBeenCalledTimes(1)
})
it('应设置定时器', async () => {
const store = useTaskStore()
getTaskList.mockResolvedValue({ tasks: [] })
getTaskQuota.mockResolvedValue(TaskFactory.createQuota())
await store.startPolling()
expect(store.timer).not.toBeNull()
})
it('不应重复启动轮询', async () => {
const store = useTaskStore()
getTaskList.mockResolvedValue({ tasks: [] })
getTaskQuota.mockResolvedValue(TaskFactory.createQuota())
await store.startPolling()
await store.startPolling()
// 只应调用一次
expect(getTaskList).toHaveBeenCalledTimes(1)
})
it('应每 5 秒执行一次轮询', async () => {
const store = useTaskStore()
getTaskList.mockResolvedValue({ tasks: [] })
getTaskQuota.mockResolvedValue(TaskFactory.createQuota())
await store.startPolling()
// 初始调用
expect(getTaskList).toHaveBeenCalledTimes(1)
// 前进 5 秒
await vi.advanceTimersByTimeAsync(5000)
expect(getTaskList).toHaveBeenCalledTimes(2)
// 再前进 5 秒
await vi.advanceTimersByTimeAsync(5000)
expect(getTaskList).toHaveBeenCalledTimes(3)
})
})
describe('stopPolling', () => {
it('应停止轮询', async () => {
const store = useTaskStore()
getTaskList.mockResolvedValue({ tasks: [] })
getTaskQuota.mockResolvedValue(TaskFactory.createQuota())
await store.startPolling()
store.stopPolling()
expect(store.timer).toBeNull()
})
it('停止后不应继续轮询', async () => {
const store = useTaskStore()
getTaskList.mockResolvedValue({ tasks: [] })
getTaskQuota.mockResolvedValue(TaskFactory.createQuota())
await store.startPolling()
store.stopPolling()
const callCount = getTaskList.mock.calls.length
// 前进 10 秒
await vi.advanceTimersByTimeAsync(10000)
// 调用次数不应增加
expect(getTaskList).toHaveBeenCalledTimes(callCount)
})
})
})
// ==================== Getters 测试 ====================
describe('Getters', () => {
describe('sidebarTasks', () => {
it('应返回最多 10 条任务', async () => {
const store = useTaskStore()
store.tasks = TaskFactory.createList(15)
expect(store.sidebarTasks).toHaveLength(10)
})
it('completed 状态进度应为 100', () => {
const store = useTaskStore()
store.tasks = [TaskFactory.createPerturbation({ status: 'completed' })]
expect(store.sidebarTasks[0].progress).toBe(100)
})
it('processing 状态进度应为 50', () => {
const store = useTaskStore()
store.tasks = [TaskFactory.createPerturbation({ status: 'processing' })]
expect(store.sidebarTasks[0].progress).toBe(50)
})
it('waiting 状态进度应为 0', () => {
const store = useTaskStore()
store.tasks = [TaskFactory.createPerturbation({ status: 'waiting' })]
expect(store.sidebarTasks[0].progress).toBe(0)
})
it('pending 状态应映射为 waiting', () => {
const store = useTaskStore()
store.tasks = [TaskFactory.createPerturbation({ status: 'pending' })]
expect(store.sidebarTasks[0].status).toBe('waiting')
})
it('无 description 时应使用任务类型特定名称', () => {
const store = useTaskStore()
const task = TaskFactory.createPerturbation({ description: '' })
task.perturbation.perturbation_name = 'Glaze'
store.tasks = [task]
expect(store.sidebarTasks[0].name).toBe('Glaze')
})
it('微调任务应使用 finetune_name', () => {
const store = useTaskStore()
const task = TaskFactory.createFinetune({ description: '' })
task.finetune.finetune_name = 'LoRA 微调'
store.tasks = [task]
expect(store.sidebarTasks[0].name).toBe('LoRA 微调')
})
})
})
})

@ -0,0 +1,234 @@
/**
* 用户状态管理单元测试
* 测试 src/stores/userStore.js 的基本功能
*/
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useUserStore } from '@/stores/userStore'
import { UserFactory } from '../../factories'
describe('UserStore 状态管理', () => {
beforeEach(() => {
// 创建新的 Pinia 实例
setActivePinia(createPinia())
// 清理 localStorage
localStorage.clear()
UserFactory.reset()
})
// ==================== 初始状态测试 ====================
describe('初始状态', () => {
it('初始状态应为未登录', () => {
const store = useUserStore()
expect(store.token).toBe('')
expect(store.userInfo).toBeNull()
expect(store.isLoggedIn).toBe(false)
})
it('应从 localStorage 恢复状态', () => {
// 预设 localStorage 数据
const mockUser = UserFactory.create()
localStorage._store['access_token'] = 'saved_token'
localStorage._store['user_info'] = JSON.stringify(mockUser)
localStorage.getItem.mockImplementation((key) => localStorage._store[key] || null)
// 重新创建 Pinia 以触发初始化
setActivePinia(createPinia())
const store = useUserStore()
expect(store.token).toBe('saved_token')
expect(store.userInfo).toEqual(mockUser)
})
})
// ==================== Getters 测试 ====================
describe('Getters', () => {
describe('isLoggedIn', () => {
it('有 token 时应返回 true', () => {
const store = useUserStore()
store.token = 'test_token'
expect(store.isLoggedIn).toBe(true)
})
it('无 token 时应返回 false', () => {
const store = useUserStore()
store.token = ''
expect(store.isLoggedIn).toBe(false)
})
})
describe('username', () => {
it('有用户信息时应返回用户名', () => {
const store = useUserStore()
store.userInfo = { username: 'testuser' }
expect(store.username).toBe('testuser')
})
it('无用户信息时应返回 Guest', () => {
const store = useUserStore()
store.userInfo = null
expect(store.username).toBe('Guest')
})
})
describe('role', () => {
it('有用户信息时应返回角色', () => {
const store = useUserStore()
store.userInfo = { role: 'admin' }
expect(store.role).toBe('admin')
})
it('无用户信息时应返回 normal', () => {
const store = useUserStore()
store.userInfo = null
expect(store.role).toBe('normal')
})
})
describe('initials', () => {
it('应返回用户名首字母大写', () => {
const store = useUserStore()
store.userInfo = { username: 'testuser' }
expect(store.initials).toBe('T')
})
it('无用户名时应返回 U', () => {
const store = useUserStore()
store.userInfo = null
expect(store.initials).toBe('U')
})
})
describe('isVip', () => {
it('admin 角色应返回 true', () => {
const store = useUserStore()
store.userInfo = { role: 'admin' }
expect(store.isVip).toBe(true)
})
it('vip 角色应返回 true', () => {
const store = useUserStore()
store.userInfo = { role: 'vip' }
expect(store.isVip).toBe(true)
})
it('普通用户应返回 false', () => {
const store = useUserStore()
store.userInfo = { role: 'user' }
expect(store.isVip).toBe(false)
})
})
})
// ==================== Actions 测试 ====================
describe('Actions', () => {
describe('setLoginData', () => {
it('应正确设置登录数据', () => {
const store = useUserStore()
const loginResponse = UserFactory.createLoginResponse()
store.setLoginData(loginResponse)
expect(store.token).toBe(loginResponse.access_token)
expect(store.userInfo).toEqual(loginResponse.user)
})
it('应将数据保存到 localStorage', () => {
const store = useUserStore()
const loginResponse = UserFactory.createLoginResponse()
store.setLoginData(loginResponse)
expect(localStorage.setItem).toHaveBeenCalledWith('access_token', loginResponse.access_token)
expect(localStorage.setItem).toHaveBeenCalledWith('user_info', JSON.stringify(loginResponse.user))
})
})
describe('updateUserInfo', () => {
it('应更新用户信息', () => {
const store = useUserStore()
store.userInfo = { username: 'oldname', role: 'user' }
store.updateUserInfo({ role: 'vip' })
expect(store.userInfo.username).toBe('oldname')
expect(store.userInfo.role).toBe('vip')
})
it('应将更新后的数据保存到 localStorage', () => {
const store = useUserStore()
store.userInfo = { username: 'testuser', role: 'user' }
store.updateUserInfo({ role: 'vip' })
expect(localStorage.setItem).toHaveBeenCalledWith(
'user_info',
expect.stringContaining('"role":"vip"')
)
})
})
describe('logout', () => {
it('应清除所有状态', () => {
const store = useUserStore()
store.token = 'test_token'
store.userInfo = { username: 'testuser' }
store.logout()
expect(store.token).toBe('')
expect(store.userInfo).toBeNull()
})
it('应清除 localStorage', () => {
const store = useUserStore()
store.token = 'test_token'
store.userInfo = { username: 'testuser' }
store.logout()
expect(localStorage.removeItem).toHaveBeenCalledWith('access_token')
expect(localStorage.removeItem).toHaveBeenCalledWith('user_info')
})
})
})
// ==================== 综合场景测试 ====================
describe('综合场景', () => {
it('登录 -> 更新 -> 登出 完整流程', () => {
const store = useUserStore()
// 1. 登录
const loginResponse = UserFactory.createLoginResponse()
store.setLoginData(loginResponse)
expect(store.isLoggedIn).toBe(true)
// 2. 更新用户信息
store.updateUserInfo({ role: 'vip' })
expect(store.isVip).toBe(true)
// 3. 登出
store.logout()
expect(store.isLoggedIn).toBe(false)
expect(store.isVip).toBe(false)
})
})
})

@ -0,0 +1,193 @@
/**
* Multipart 解析器单元测试
* 测试 src/utils/multipartParser.js 的基本功能
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { parseMultipartMixed } from '@/utils/multipartParser'
describe('MultipartParser 工具函数', () => {
// Mock URL.createObjectURL
beforeEach(() => {
let blobUrlCounter = 0
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => `blob:http://localhost/${blobUrlCounter++}`),
revokeObjectURL: vi.fn()
})
})
afterEach(() => {
vi.unstubAllGlobals()
})
// ==================== 辅助函数 ====================
/**
* 创建 Multipart 响应的 ArrayBuffer
*/
function createMultipartBuffer(boundary, parts) {
const encoder = new TextEncoder()
let content = ''
parts.forEach((part) => {
content += `--${boundary}\r\n`
content += `Content-Type: ${part.contentType || 'image/png'}\r\n`
content += `X-Image-Type: ${part.imageType || 'original'}\r\n`
content += `X-Image-Id: ${part.imageId || '1'}\r\n`
content += '\r\n'
content += part.data || 'fake image data'
content += '\r\n'
})
content += `--${boundary}--\r\n`
return encoder.encode(content).buffer
}
// ==================== 基本解析测试 ====================
describe('parseMultipartMixed', () => {
it('应正确解析单个图片', () => {
const boundary = 'test-boundary-123'
const buffer = createMultipartBuffer(boundary, [
{ imageType: 'original', imageId: '1', data: 'image data 1' }
])
const result = parseMultipartMixed(buffer, boundary)
expect(result.images).toBeDefined()
expect(result.images.original).toHaveLength(1)
expect(result.images.original[0].image_id).toBe('1')
expect(result.images.original[0].data).toMatch(/^blob:/)
})
it('应正确解析多个图片', () => {
const boundary = 'test-boundary-456'
const buffer = createMultipartBuffer(boundary, [
{ imageType: 'original', imageId: '1', data: 'image data 1' },
{ imageType: 'original', imageId: '2', data: 'image data 2' },
{ imageType: 'perturbed', imageId: '3', data: 'image data 3' }
])
const result = parseMultipartMixed(buffer, boundary)
expect(result.images.original).toHaveLength(2)
expect(result.images.perturbed).toHaveLength(1)
})
it('应正确分类不同类型的图片', () => {
const boundary = 'test-boundary-789'
const buffer = createMultipartBuffer(boundary, [
{ imageType: 'original', imageId: '1' },
{ imageType: 'perturbed', imageId: '2' },
{ imageType: 'original_generate', imageId: '3' },
{ imageType: 'perturbed_generate', imageId: '4' },
{ imageType: 'heatmap', imageId: '5' }
])
const result = parseMultipartMixed(buffer, boundary)
expect(result.images.original).toHaveLength(1)
expect(result.images.perturbed).toHaveLength(1)
expect(result.images.original_generate).toHaveLength(1)
expect(result.images.perturbed_generate).toHaveLength(1)
expect(result.images.heatmap).toHaveLength(1)
})
it('应为每个图片创建 Blob URL', () => {
const boundary = 'test-boundary-blob'
const buffer = createMultipartBuffer(boundary, [
{ imageType: 'original', imageId: '1' }
])
const result = parseMultipartMixed(buffer, boundary)
expect(URL.createObjectURL).toHaveBeenCalled()
expect(result.images.original[0].data).toMatch(/^blob:/)
expect(result.images.original[0].blob).toBeInstanceOf(Blob)
})
it('应保留 Blob 对象用于后续操作', () => {
const boundary = 'test-boundary-retain'
const buffer = createMultipartBuffer(boundary, [
{ imageType: 'original', imageId: '1', contentType: 'image/jpeg' }
])
const result = parseMultipartMixed(buffer, boundary)
expect(result.images.original[0].blob).toBeDefined()
expect(result.images.original[0].blob.type).toBe('image/jpeg')
})
})
// ==================== 边界情况测试 ====================
describe('边界情况', () => {
it('空 buffer 应返回空的图片分类', () => {
const boundary = 'empty-boundary'
const buffer = new ArrayBuffer(0)
const result = parseMultipartMixed(buffer, boundary)
expect(result.images.original).toHaveLength(0)
expect(result.images.perturbed).toHaveLength(0)
})
it('未知图片类型应被忽略', () => {
const boundary = 'unknown-type-boundary'
const buffer = createMultipartBuffer(boundary, [
{ imageType: 'unknown_type', imageId: '1' }
])
const result = parseMultipartMixed(buffer, boundary)
// 未知类型不应出现在任何分类中
expect(result.images.original).toHaveLength(0)
expect(result.images.perturbed).toHaveLength(0)
})
it('应正确处理不同的 Content-Type', () => {
const boundary = 'content-type-boundary'
const buffer = createMultipartBuffer(boundary, [
{ imageType: 'original', imageId: '1', contentType: 'image/png' },
{ imageType: 'original', imageId: '2', contentType: 'image/jpeg' }
])
const result = parseMultipartMixed(buffer, boundary)
expect(result.images.original).toHaveLength(2)
})
})
// ==================== 返回结构测试 ====================
describe('返回结构', () => {
it('应包含所有预定义的图片类型分类', () => {
const boundary = 'structure-boundary'
const buffer = createMultipartBuffer(boundary, [])
const result = parseMultipartMixed(buffer, boundary)
expect(result.images).toHaveProperty('original')
expect(result.images).toHaveProperty('perturbed')
expect(result.images).toHaveProperty('original_generate')
expect(result.images).toHaveProperty('perturbed_generate')
expect(result.images).toHaveProperty('uploaded_generate')
expect(result.images).toHaveProperty('heatmap')
expect(result.images).toHaveProperty('report')
})
it('每个图片对象应包含必要的属性', () => {
const boundary = 'props-boundary'
const buffer = createMultipartBuffer(boundary, [
{ imageType: 'original', imageId: '123' }
])
const result = parseMultipartMixed(buffer, boundary)
const image = result.images.original[0]
expect(image).toHaveProperty('image_id')
expect(image).toHaveProperty('data')
expect(image).toHaveProperty('blob')
})
})
})

@ -0,0 +1,163 @@
/**
* 导航栏高亮位置计算单元测试
* 测试 src/utils/navbarHighlight.js 的基本功能
*/
import { describe, it, expect } from 'vitest'
import {
calculateItemHeightPercent,
calculateHighlightTop,
calculateHighlightHeight,
isHighlightWithinBounds
} from '@/utils/navbarHighlight'
describe('NavbarHighlight 工具函数', () => {
// ==================== calculateItemHeightPercent 测试 ====================
describe('calculateItemHeightPercent', () => {
it('5 个导航项时每项高度应为 16%', () => {
expect(calculateItemHeightPercent(5)).toBe(16)
})
it('4 个导航项时每项高度应为 20%', () => {
expect(calculateItemHeightPercent(4)).toBe(20)
})
it('8 个导航项时每项高度应为 10%', () => {
expect(calculateItemHeightPercent(8)).toBe(10)
})
it('1 个导航项时每项高度应为 80%', () => {
expect(calculateItemHeightPercent(1)).toBe(80)
})
it('导航项数量为 0 时应抛出错误', () => {
expect(() => calculateItemHeightPercent(0)).toThrow('navItemsCount must be greater than 0')
})
it('导航项数量为负数时应抛出错误', () => {
expect(() => calculateItemHeightPercent(-1)).toThrow('navItemsCount must be greater than 0')
})
})
// ==================== calculateHighlightTop 测试 ====================
describe('calculateHighlightTop', () => {
it('5 个导航项时第一项 (index=0) 的 top 应为 10%', () => {
expect(calculateHighlightTop(0, 5)).toBe(10)
})
it('5 个导航项时第二项 (index=1) 的 top 应为 26%', () => {
expect(calculateHighlightTop(1, 5)).toBe(26)
})
it('5 个导航项时第三项 (index=2) 的 top 应为 42%', () => {
expect(calculateHighlightTop(2, 5)).toBe(42)
})
it('5 个导航项时第四项 (index=3) 的 top 应为 58%', () => {
expect(calculateHighlightTop(3, 5)).toBe(58)
})
it('5 个导航项时第五项 (index=4) 的 top 应为 74%', () => {
expect(calculateHighlightTop(4, 5)).toBe(74)
})
it('4 个导航项时第一项的 top 应为 10%', () => {
expect(calculateHighlightTop(0, 4)).toBe(10)
})
it('4 个导航项时最后一项的 top 应为 70%', () => {
expect(calculateHighlightTop(3, 4)).toBe(70)
})
it('activeIndex 为负数时应抛出错误', () => {
expect(() => calculateHighlightTop(-1, 5)).toThrow()
})
it('activeIndex 超出范围时应抛出错误', () => {
expect(() => calculateHighlightTop(5, 5)).toThrow()
})
it('navItemsCount 为 0 时应抛出错误', () => {
expect(() => calculateHighlightTop(0, 0)).toThrow()
})
})
// ==================== calculateHighlightHeight 测试 ====================
describe('calculateHighlightHeight', () => {
it('应返回与 calculateItemHeightPercent 相同的值', () => {
expect(calculateHighlightHeight(5)).toBe(calculateItemHeightPercent(5))
expect(calculateHighlightHeight(4)).toBe(calculateItemHeightPercent(4))
expect(calculateHighlightHeight(8)).toBe(calculateItemHeightPercent(8))
})
})
// ==================== isHighlightWithinBounds 测试 ====================
describe('isHighlightWithinBounds', () => {
it('高亮在有效范围内应返回 true', () => {
// 5 个导航项,第一项: top=10, height=16, bottom=26
expect(isHighlightWithinBounds(10, 16)).toBe(true)
// 5 个导航项,最后一项: top=74, height=16, bottom=90
expect(isHighlightWithinBounds(74, 16)).toBe(true)
})
it('高亮超出上边界应返回 false', () => {
expect(isHighlightWithinBounds(5, 16)).toBe(false)
})
it('高亮超出下边界应返回 false', () => {
expect(isHighlightWithinBounds(80, 16)).toBe(false)
})
it('高亮刚好在边界上应返回 true', () => {
// 刚好在上边界: top=10
expect(isHighlightWithinBounds(10, 80)).toBe(true)
// 刚好在下边界: top + height = 90
expect(isHighlightWithinBounds(10, 80)).toBe(true)
})
})
// ==================== 综合测试 ====================
describe('综合场景测试', () => {
it('所有导航项的高亮位置都应在有效范围内', () => {
const navItemsCount = 5
for (let i = 0; i < navItemsCount; i++) {
const top = calculateHighlightTop(i, navItemsCount)
const height = calculateHighlightHeight(navItemsCount)
expect(isHighlightWithinBounds(top, height)).toBe(true)
}
})
it('不同导航项数量的高亮位置都应在有效范围内', () => {
const testCases = [1, 2, 3, 4, 5, 6, 7, 8, 10]
testCases.forEach(navItemsCount => {
for (let i = 0; i < navItemsCount; i++) {
const top = calculateHighlightTop(i, navItemsCount)
const height = calculateHighlightHeight(navItemsCount)
expect(isHighlightWithinBounds(top, height)).toBe(true)
}
})
})
it('高亮位置应随 activeIndex 递增', () => {
const navItemsCount = 5
let previousTop = -1
for (let i = 0; i < navItemsCount; i++) {
const top = calculateHighlightTop(i, navItemsCount)
expect(top).toBeGreaterThan(previousTop)
previousTop = top
}
})
})
})

@ -0,0 +1,278 @@
/**
* 主题工具函数单元测试
* 测试 src/utils/theme.js 的基本功能
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import {
VALID_THEMES,
DEFAULT_THEME,
LAYOUT_PROPERTIES,
COLOR_PROPERTIES,
isValidTheme,
normalizeTheme,
saveThemePreference,
loadThemePreference,
themeRoundTrip,
applyTheme,
getCurrentTheme,
toggleTheme,
extractLayoutProperties,
layoutPropertiesEqual
} from '@/utils/theme'
describe('Theme 工具函数', () => {
// ==================== 常量测试 ====================
describe('常量定义', () => {
it('VALID_THEMES 应包含 dark 和 light', () => {
expect(VALID_THEMES).toContain('dark')
expect(VALID_THEMES).toContain('light')
expect(VALID_THEMES.length).toBe(2)
})
it('DEFAULT_THEME 应为 dark', () => {
expect(DEFAULT_THEME).toBe('dark')
})
it('LAYOUT_PROPERTIES 应包含布局相关属性', () => {
expect(LAYOUT_PROPERTIES).toContain('width')
expect(LAYOUT_PROPERTIES).toContain('height')
expect(LAYOUT_PROPERTIES).toContain('padding')
expect(LAYOUT_PROPERTIES).toContain('margin')
expect(LAYOUT_PROPERTIES).toContain('fontSize')
})
it('COLOR_PROPERTIES 应包含颜色相关属性', () => {
expect(COLOR_PROPERTIES).toContain('backgroundColor')
expect(COLOR_PROPERTIES).toContain('color')
expect(COLOR_PROPERTIES).toContain('borderColor')
})
})
// ==================== isValidTheme 测试 ====================
describe('isValidTheme', () => {
it('dark 应为有效主题', () => {
expect(isValidTheme('dark')).toBe(true)
})
it('light 应为有效主题', () => {
expect(isValidTheme('light')).toBe(true)
})
it('无效主题应返回 false', () => {
expect(isValidTheme('invalid')).toBe(false)
expect(isValidTheme('')).toBe(false)
expect(isValidTheme(null)).toBe(false)
expect(isValidTheme(undefined)).toBe(false)
})
})
// ==================== normalizeTheme 测试 ====================
describe('normalizeTheme', () => {
it('有效主题应原样返回', () => {
expect(normalizeTheme('dark')).toBe('dark')
expect(normalizeTheme('light')).toBe('light')
})
it('无效主题应返回默认主题', () => {
expect(normalizeTheme('invalid')).toBe(DEFAULT_THEME)
expect(normalizeTheme('')).toBe(DEFAULT_THEME)
expect(normalizeTheme(null)).toBe(DEFAULT_THEME)
expect(normalizeTheme(undefined)).toBe(DEFAULT_THEME)
})
})
// ==================== localStorage 相关测试 ====================
describe('saveThemePreference', () => {
beforeEach(() => {
localStorage.clear()
})
it('应成功保存有效主题', () => {
const result = saveThemePreference('dark')
expect(result).toBe(true)
expect(localStorage.setItem).toHaveBeenCalledWith('theme', 'dark')
})
it('保存无效主题时应保存默认主题', () => {
const result = saveThemePreference('invalid')
expect(result).toBe(true)
expect(localStorage.setItem).toHaveBeenCalledWith('theme', DEFAULT_THEME)
})
})
describe('loadThemePreference', () => {
beforeEach(() => {
localStorage.clear()
})
it('应加载已保存的主题', () => {
localStorage._store['theme'] = 'light'
localStorage.getItem.mockReturnValue('light')
const result = loadThemePreference()
expect(result).toBe('light')
})
it('无保存主题时应返回默认主题', () => {
localStorage.getItem.mockReturnValue(null)
const result = loadThemePreference()
expect(result).toBe(DEFAULT_THEME)
})
})
describe('themeRoundTrip', () => {
beforeEach(() => {
localStorage.clear()
})
it('有效主题应能完成往返', () => {
// 模拟 localStorage 行为
let savedTheme = null
localStorage.setItem.mockImplementation((key, value) => {
if (key === 'theme') savedTheme = value
})
localStorage.getItem.mockImplementation((key) => {
if (key === 'theme') return savedTheme
return null
})
const result = themeRoundTrip('light')
expect(result).toBe('light')
})
it('无效主题往返后应返回默认主题', () => {
let savedTheme = null
localStorage.setItem.mockImplementation((key, value) => {
if (key === 'theme') savedTheme = value
})
localStorage.getItem.mockImplementation((key) => {
if (key === 'theme') return savedTheme
return null
})
const result = themeRoundTrip('invalid')
expect(result).toBe(DEFAULT_THEME)
})
})
// ==================== DOM 相关测试 ====================
describe('applyTheme', () => {
beforeEach(() => {
document.documentElement.classList.remove('dark-mode')
})
it('应用 dark 主题应添加 dark-mode 类', () => {
applyTheme('dark')
expect(document.documentElement.classList.contains('dark-mode')).toBe(true)
})
it('应用 light 主题应移除 dark-mode 类', () => {
document.documentElement.classList.add('dark-mode')
applyTheme('light')
expect(document.documentElement.classList.contains('dark-mode')).toBe(false)
})
})
describe('getCurrentTheme', () => {
it('有 dark-mode 类时应返回 dark', () => {
document.documentElement.classList.add('dark-mode')
expect(getCurrentTheme()).toBe('dark')
})
it('无 dark-mode 类时应返回 light', () => {
document.documentElement.classList.remove('dark-mode')
expect(getCurrentTheme()).toBe('light')
})
})
describe('toggleTheme', () => {
beforeEach(() => {
localStorage.clear()
let savedTheme = null
localStorage.setItem.mockImplementation((key, value) => {
if (key === 'theme') savedTheme = value
})
})
it('从 dark 切换到 light', () => {
document.documentElement.classList.add('dark-mode')
const newTheme = toggleTheme()
expect(newTheme).toBe('light')
expect(document.documentElement.classList.contains('dark-mode')).toBe(false)
})
it('从 light 切换到 dark', () => {
document.documentElement.classList.remove('dark-mode')
const newTheme = toggleTheme()
expect(newTheme).toBe('dark')
expect(document.documentElement.classList.contains('dark-mode')).toBe(true)
})
})
// ==================== 布局属性测试 ====================
describe('extractLayoutProperties', () => {
it('应提取所有布局属性', () => {
const mockStyle = {
width: '100px',
height: '200px',
padding: '10px',
paddingTop: '10px',
paddingRight: '10px',
paddingBottom: '10px',
paddingLeft: '10px',
margin: '5px',
marginTop: '5px',
marginRight: '5px',
marginBottom: '5px',
marginLeft: '5px',
gridTemplateColumns: 'auto',
gridTemplateRows: 'auto',
gap: '10px',
fontSize: '16px',
lineHeight: '1.5',
letterSpacing: 'normal'
}
const result = extractLayoutProperties(mockStyle)
expect(result.width).toBe('100px')
expect(result.height).toBe('200px')
expect(result.fontSize).toBe('16px')
})
})
describe('layoutPropertiesEqual', () => {
it('相同布局属性应返回 true', () => {
const before = { width: '100px', height: '200px', padding: '10px' }
const after = { width: '100px', height: '200px', padding: '10px' }
// 需要完整的属性列表
LAYOUT_PROPERTIES.forEach(prop => {
if (!before[prop]) before[prop] = ''
if (!after[prop]) after[prop] = ''
})
expect(layoutPropertiesEqual(before, after)).toBe(true)
})
it('不同布局属性应返回 false', () => {
const before = { width: '100px' }
const after = { width: '200px' }
LAYOUT_PROPERTIES.forEach(prop => {
if (!before[prop]) before[prop] = ''
if (!after[prop]) after[prop] = ''
})
expect(layoutPropertiesEqual(before, after)).toBe(false)
})
})
})

@ -0,0 +1,86 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
test: {
// 测试环境
environment: 'jsdom',
// 全局设置
globals: true,
// 设置文件
setupFiles: ['./test/setup.js'],
// 测试文件匹配模式
include: [
'test/**/*.test.js',
'test/**/*.property.test.js'
],
// 排除文件
exclude: [
'node_modules',
'dist'
],
// 模块别名 - 用于 mock Three.js
alias: {
'three': resolve(__dirname, 'test/__mocks__/three.js')
},
// 覆盖率配置
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
reportsDirectory: './coverage',
include: [
'src/**/*.js',
'src/**/*.vue'
],
exclude: [
'src/main.js',
'src/router/**',
'**/*.test.js'
],
// 覆盖率阈值
thresholds: {
lines: 60,
functions: 60,
branches: 60,
statements: 60
}
},
// 超时设置
testTimeout: 10000,
hookTimeout: 10000,
// 并发设置
pool: 'threads',
poolOptions: {
threads: {
singleThread: false
}
},
// 报告器
reporters: ['default'],
// 监听模式配置
watch: false,
// 快照配置
snapshotFormat: {
escapeString: true,
printBasicPrototype: true
}
},
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
Loading…
Cancel
Save