feat: 实现剧本创作与拆解前端工作台

This commit is contained in:
GouJ
2026-08-27 19:33:51 +08:00
parent edc003443f
commit 2ee6f049f6
56 changed files with 7597 additions and 2 deletions
+86
View File
@@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ApiError, optionalResource, request } from './http'
import { projectsApi } from '../features/projects/api'
import { breakdownApi } from '../features/breakdown/api'
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
})
describe('后端 API 契约', () => {
it('解包 data,同时保留创建项目的顶层 202 结构', async () => {
const fetcher = vi
.fn<(url: string, options: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(new Response(JSON.stringify({ data: [{ id: 'p1' }] })))
.mockResolvedValueOnce(
new Response(JSON.stringify({ projectId: 'p2', status: 'generating' }), { status: 202 })
)
vi.stubGlobal('fetch', fetcher)
expect(await projectsApi.list()).toEqual([{ id: 'p1' }])
expect(await projectsApi.create({ topic: '故事', style: '悬疑', episodeCount: 3 })).toEqual({
projectId: 'p2',
status: 'generating'
})
expect(JSON.parse(fetcher.mock.calls[1]![1].body as string)).toEqual({
topic: '故事',
style: '悬疑',
episodeCount: 3
})
})
it('只将缺少 checkpoint 的 404 视为可选结果,保留服务器错误', async () => {
expect(await optionalResource(Promise.reject(new ApiError('没有 checkpoint', 404)))).toBeNull()
await expect(optionalResource(Promise.reject(new ApiError('数据库异常', 500)))).rejects.toMatchObject({
status: 500
})
})
it('保留错误细节且拒绝 HTML 代理错误', async () => {
vi.stubGlobal(
'fetch',
vi
.fn<(url: string, options: RequestInit) => Promise<Response>>()
.mockResolvedValueOnce(
new Response(JSON.stringify({ message: '校验失败', issues: ['缺少形态'] }), { status: 400 })
)
.mockResolvedValueOnce(new Response('<html>Bad Gateway</html>', { status: 502 }))
)
await expect(request('/projects')).rejects.toMatchObject({ status: 400, details: ['缺少形态'] })
await expect(request('/projects')).rejects.toThrow('接口未返回 JSON')
})
it('预览模块使用逗号参数,三种恢复不误发到 start', async () => {
const fetcher = vi
.fn<(url: string, options: RequestInit) => Promise<Response>>()
.mockImplementation(() => Promise.resolve(new Response('{"data":{}}')))
vi.stubGlobal('fetch', fetcher)
await breakdownApi.preview('a/b', { groupSize: 3, modules: ['character', 'scene'] })
for (const action of ['retry', 'resume-shots', 'resume-storyboard'] as const)
await breakdownApi.run('p', action)
expect(fetcher.mock.calls.map(call => call[0])).toEqual([
'/api/projects/a%2Fb/breakdown-preview?groupSize=3&modules=character%2Cscene',
'/api/projects/p/breakdown/retry',
'/api/projects/p/breakdown/resume-shots',
'/api/projects/p/breakdown/resume-storyboard'
])
})
it('长工作流不使用普通查询的超时,不自动重试 POST', async () => {
vi.useFakeTimers()
let finish!: (value: Response) => void
const fetcher = vi.fn<(url: string, options: RequestInit) => Promise<Response>>().mockImplementation(
() =>
new Promise<Response>(resolve => {
finish = resolve
})
)
vi.stubGlobal('fetch', fetcher)
const running = breakdownApi.run('p', 'start', { groupSize: 3, modules: ['prop'] })
await vi.advanceTimersByTimeAsync(120_000)
expect(fetcher).toHaveBeenCalledTimes(1)
expect(fetcher.mock.calls[0]![1].signal?.aborted).toBe(false)
finish(new Response('{"data":{"workflowExecution":{"status":"failed"}}}'))
expect(await running).toMatchObject({ workflowExecution: { status: 'failed' } })
})
})