|
After Width: | Height: | Size: 567 KiB |
|
After Width: | Height: | Size: 763 KiB |
|
After Width: | Height: | Size: 597 KiB |
|
After Width: | Height: | Size: 435 KiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 779 KiB |
|
After Width: | Height: | Size: 709 KiB |
|
After Width: | Height: | Size: 921 KiB |
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 881 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 3.4 MiB |
@ -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,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,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,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,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')
|
||||
}
|
||||
}
|
||||
})
|
||||