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:
parent
e71f2efc1a
commit
b6c1d4a02c
2332
game-studio/package-lock.json
generated
2332
game-studio/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
122
game-studio/src/store/create.spec.ts
Normal file
122
game-studio/src/store/create.spec.ts
Normal file
@ -0,0 +1,122 @@
|
||||
/**
|
||||
* create.spec.ts —— useCreateStore(studio 一步式任务链编排)单测(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('useCreateStore(studio 一步式任务链编排)', () => {
|
||||
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)
|
||||
})
|
||||
})
|
||||
99
game-studio/src/views/create/Create.spec.ts
Normal file
99
game-studio/src/views/create/Create.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
16
game-studio/vitest.config.ts
Normal file
16
game-studio/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user