test(studio): E6/U6 创作流单测——vitest + store 编排 + Create.vue(plan 命名件 Create.spec.ts)

补 plan 002 U6 命名交付件 + 回归保护我 U6 重构(此前仅 build+HTTP+CDP,无单测):
- 引入 vitest 3.2.6 + @vue/test-utils + happy-dom(Vite8 标准测试栈)+ vitest.config.ts(独立于 vite.config 避 mock 中间件)+ `npm test` 脚本
- src/store/create.spec.ts(8 测):vi.mock studioApi 验编排——loadTemplates/create/pollTask 终态停轮询(fake timers)/modify/extend/regenerate/reset
- src/views/create/Create.spec.ts(4 测):mount + 填 Prompt 启用提交 + 提交调 createGame(一步式)+ router.push 进度页带 gameId;验不走旧两步

验证:`npm test` 12/12 绿;`npm run build`(vue-tsc -b)仍绿(spec 经 import 取 vitest 类型,不污染产物 bundle)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
lili 2026-06-17 16:36:42 -07:00
parent e71f2efc1a
commit b6c1d4a02c
5 changed files with 2544 additions and 31 deletions

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,8 @@
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"axios": "^1.17.0",
@ -19,9 +20,12 @@
"devDependencies": {
"@types/node": "^24.12.3",
"@vitejs/plugin-vue": "^6.0.6",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"happy-dom": "^20.10.5",
"typescript": "~6.0.2",
"vite": "^8.0.12",
"vitest": "^3.2.6",
"vue-tsc": "^3.2.8"
}
}

View File

@ -0,0 +1,122 @@
/**
* create.spec.ts useCreateStorestudio plan 002 U6
* store studio.yaml 的编排逻辑:loadTemplates / create / pollTask / modify / extend / regenerate / reset
* studioApi vi.mock store `import * as studioApi` fake timers `npm test`
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
// mock 整个 studio api 模块store 经 `import * as studioApi` 消费)
vi.mock('../api/studio', () => ({
getTemplates: vi.fn(),
createGame: vi.fn(),
modifyGame: vi.fn(),
extendGame: vi.fn(),
getTaskChain: vi.fn(),
regenerate: vi.fn(),
}))
import * as studioApi from '../api/studio'
import { useCreateStore } from './create'
/** 取 mock 句柄(类型收窄便于断言) */
const api = studioApi as unknown as Record<string, ReturnType<typeof vi.fn>>
const POLL_INTERVAL = 1200 // 与 store 内常量一致
describe('useCreateStorestudio 一步式任务链编排)', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('loadTemplates → 调 studio.getTemplates 并落 templates', async () => {
api.getTemplates.mockResolvedValue([{ templateId: 'generic', name: '通用' }])
const s = useCreateStore()
await s.loadTemplates()
expect(api.getTemplates).toHaveBeenCalledOnce()
expect(s.templates).toHaveLength(1)
expect(s.templates[0].templateId).toBe('generic')
})
it('create → 调 createGame(req) 返任务链 + 落 currentChain/progress', async () => {
api.createGame.mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 0 })
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 0 })
const s = useCreateStore()
const chain = await s.create({ title: 'T', prompt: '躲陨石', templateId: 'generic' })
expect(api.createGame).toHaveBeenCalledWith({ title: 'T', prompt: '躲陨石', templateId: 'generic' })
expect(chain.id).toBe(1001)
expect(s.currentChain?.id).toBe(1001)
})
it('pollTask 首拉即终态(status=2) → 停轮询、回填 versionId、不起 interval', async () => {
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 2, progress: 100, versionId: 9001 })
const s = useCreateStore()
await s.pollTask(1001)
expect(s.currentChain?.status).toBe(2)
expect(s.currentChain?.versionId).toBe(9001)
expect(s.polling).toBe(false)
// 终态后不应再有定时轮询:推进时间不再触发 getTaskChain
api.getTaskChain.mockClear()
await vi.advanceTimersByTimeAsync(POLL_INTERVAL * 2)
expect(api.getTaskChain).not.toHaveBeenCalled()
})
it('pollTask 进行中→定时轮询至终态停fake timers 驱动)', async () => {
api.getTaskChain
.mockResolvedValueOnce({ id: 1001, gameId: 1, status: 1, progress: 40 })
.mockResolvedValue({ id: 1001, gameId: 1, status: 2, progress: 100, versionId: 9001 })
const s = useCreateStore()
await s.pollTask(1001) // 首拉:进行中(40) → 起 interval
expect(s.polling).toBe(true)
expect(s.progress).toBe(40)
await vi.advanceTimersByTimeAsync(POLL_INTERVAL) // 第二拉:终态 → 停
expect(s.currentChain?.status).toBe(2)
expect(s.polling).toBe(false)
})
it('modify → 调 modifyGame(req) 落 currentChain', async () => {
const req = { baseVersionId: 9001, mode: 'deterministic' as const, target: { kind: 'config' as const, path: '/config/speed' }, payload: { value: { speed: 5 } } }
api.modifyGame.mockResolvedValue({ id: 1002, gameId: 1, status: 1, progress: 0, baseVersionId: 9001 })
api.getTaskChain.mockResolvedValue({ id: 1002, gameId: 1, status: 1, progress: 0, baseVersionId: 9001 })
const s = useCreateStore()
const chain = await s.modify(req)
expect(api.modifyGame).toHaveBeenCalledWith(req)
expect(chain.baseVersionId).toBe(9001)
expect(s.currentChain?.id).toBe(1002)
})
it('extend → 调 extendGame(req) 落 currentChain', async () => {
const req = { baseVersionId: 9001, intent: '再加一关' }
api.extendGame.mockResolvedValue({ id: 1003, gameId: 1, status: 1, progress: 0, baseVersionId: 9001 })
api.getTaskChain.mockResolvedValue({ id: 1003, gameId: 1, status: 1, progress: 0, baseVersionId: 9001 })
const s = useCreateStore()
const chain = await s.extend(req)
expect(api.extendGame).toHaveBeenCalledWith(req)
expect(chain.id).toBe(1003)
})
it('regenerate → 调 regenerate(id) 落新链', async () => {
api.regenerate.mockResolvedValue({ id: 1004, gameId: 1, status: 1, progress: 0, retryOf: 1001 })
api.getTaskChain.mockResolvedValue({ id: 1004, gameId: 1, status: 1, progress: 0, retryOf: 1001 })
const s = useCreateStore()
const chain = await s.regenerate(1001)
expect(api.regenerate).toHaveBeenCalledWith(1001)
expect(chain.retryOf).toBe(1001)
expect(s.currentChain?.id).toBe(1004)
})
it('reset → 清 currentChain/progress + 停轮询', async () => {
api.getTaskChain.mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 30 })
const s = useCreateStore()
await s.pollTask(1001)
expect(s.polling).toBe(true)
s.reset()
expect(s.currentChain).toBeNull()
expect(s.progress).toBe(0)
expect(s.polling).toBe(false)
})
})

View File

@ -0,0 +1,99 @@
/**
* Create.spec.ts plan 002 U6
* :渲染 Prompt createStore.create() router.push
* mock vue-router/studioApi ;child stub `npm test`
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { setActivePinia, createPinia } from 'pinia'
import { createI18n } from 'vue-i18n'
// router push 间谍
const push = vi.fn()
vi.mock('vue-router', () => ({ useRouter: () => ({ push }) }))
// mock studio api让真 store 无需 HTTP 即可跑)
vi.mock('../../api/studio', () => ({
getTemplates: vi.fn().mockResolvedValue([{ templateId: 'generic', name: '通用', description: '一句话直生', examplePrompt: '做个躲陨石' }]),
createGame: vi.fn().mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 0 }),
getTaskChain: vi.fn().mockResolvedValue({ id: 1001, gameId: 1, status: 1, progress: 0 }),
modifyGame: vi.fn(),
extendGame: vi.fn(),
regenerate: vi.fn(),
}))
import * as studioApi from '../../api/studio'
import Create from './Create.vue'
const api = studioApi as unknown as Record<string, ReturnType<typeof vi.fn>>
const i18n = createI18n({ legacy: false, locale: 'zh-CN', messages: { 'zh-CN': {} }, missingWarn: false, fallbackWarn: false })
/** 可点击的 AppButton stub透传 disabled + 转发 click供提交测试 */
const AppButtonStub = {
props: ['disabled', 'loading', 'block', 'size', 'type'],
emits: ['click'],
template: '<button class="app-btn" :disabled="disabled" @click="$emit(\'click\')"><slot/></button>',
}
function mountCreate() {
setActivePinia(createPinia())
return mount(Create, {
global: {
plugins: [i18n],
stubs: { LoadingBar: true, EmptyState: true, Icon: true, AppButton: AppButtonStub },
},
})
}
describe('Create.vue一步式创作入口', () => {
beforeEach(() => {
vi.clearAllMocks()
push.mockClear()
})
it('挂载即拉模板studio.getTemplates', async () => {
mountCreate()
await flushPromises()
expect(api.getTemplates).toHaveBeenCalledOnce()
})
it('Prompt 空时提交禁用;填入后启用', async () => {
const w = mountCreate()
await flushPromises()
const btn = w.find('.create-foot .app-btn')
expect(btn.attributes('disabled')).toBeDefined() // 空 Prompt → 禁用
await w.find('textarea.prompt-input').setValue('做一个躲避陨石的小游戏')
expect(btn.attributes('disabled')).toBeUndefined() // 有 Prompt → 启用
})
it('提交 → 调 studio.createGame(一步式) 并 router.push 进度页(带 gameId)', async () => {
const w = mountCreate()
await flushPromises()
await w.find('textarea.prompt-input').setValue('做一个躲避陨石的小游戏')
await w.find('.create-foot .app-btn').trigger('click')
await flushPromises()
// 一步式:createGame 被调(title 取 Prompt 前 30 字、templateId 缺省 generic)
expect(api.createGame).toHaveBeenCalledOnce()
const arg = api.createGame.mock.calls[0][0]
expect(arg.prompt).toBe('做一个躲避陨石的小游戏')
expect(arg.templateId).toBe('generic')
// 跳进度页,带任务链 id + gameId
expect(push).toHaveBeenCalledOnce()
const nav = push.mock.calls[0][0]
expect(nav.name).toBe('create-task')
expect(nav.params.taskId).toBe('1001')
expect(nav.query.gameId).toBe('1')
})
it('不走旧两步:不调 project.create仅一步式 studio.create', async () => {
const w = mountCreate()
await flushPromises()
await w.find('textarea.prompt-input').setValue('点目标小游戏')
await w.find('.create-foot .app-btn').trigger('click')
await flushPromises()
expect(api.createGame).toHaveBeenCalled()
// 旧两步的 aigc.generate 已不在创作流store 仅用 studio.*——createGame 是唯一生成入口
expect(api.modifyGame).not.toHaveBeenCalled()
expect(api.extendGame).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,16 @@
/**
* vitest ( vite.config.ts mock dev/preview mock server)
* happy-dom @vue/test-utils + store ( DOM)
* U6 (plan 002 U6 Create.spec.ts + store ) `npm test`
*/
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'happy-dom',
globals: true,
include: ['src/**/*.spec.ts'],
},
})