144 lines
6.2 KiB
TypeScript
144 lines
6.2 KiB
TypeScript
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import { mediaAssetUrl } from '@/lib/assets'
|
|
import KeyframeDialog from '@/features/production/components/KeyframeDialog.vue'
|
|
import { productionApi } from '@/features/production/api'
|
|
import {
|
|
isActiveVideo,
|
|
issueLabel,
|
|
primaryKeyframe,
|
|
primaryVideo,
|
|
productionStatusLabel,
|
|
validOptionalSize
|
|
} from '@/features/production/model'
|
|
import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
|
|
|
|
let wrapper: VueWrapper | undefined
|
|
afterEach(() => {
|
|
wrapper?.unmount()
|
|
wrapper = undefined
|
|
document.body.innerHTML = ''
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
/** 从 Naive 弹窗中查找精确按钮。 */
|
|
function button(label: string): HTMLButtonElement {
|
|
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
|
if (!item) throw new Error(`缺少按钮 ${label}`)
|
|
return item
|
|
}
|
|
|
|
describe('镜头生产数据契约', () => {
|
|
it('可选尺寸必须成对留空或填写正整数', () => {
|
|
expect(validOptionalSize('', '')).toBe(true)
|
|
expect(validOptionalSize(1920, 1080)).toBe(true)
|
|
expect(validOptionalSize(1920, '')).toBe(false)
|
|
expect(validOptionalSize('', 1080)).toBe(false)
|
|
expect(validOptionalSize(0, 1080)).toBe(false)
|
|
expect(validOptionalSize(10.5, 1080)).toBe(false)
|
|
})
|
|
|
|
it('主资产只接受已完成且具有地址的记录,活动视频覆盖三种状态', () => {
|
|
expect(primaryKeyframe([keyframeFixture()])?.id).toBe('keyframe-1')
|
|
expect(primaryKeyframe([keyframeFixture({ status: 'failed' })])).toBeUndefined()
|
|
expect(primaryVideo([videoFixture()])?.id).toBe('video-1')
|
|
expect(primaryVideo([videoFixture({ videoUrl: null })])).toBeUndefined()
|
|
for (const status of ['pending', 'queued', 'running'] as const)
|
|
expect(isActiveVideo(videoFixture({ status }))).toBe(true)
|
|
expect(isActiveVideo(videoFixture())).toBe(false)
|
|
})
|
|
|
|
it('就绪问题和异步任务状态提供中文标签', () => {
|
|
expect(issueLabel('missing_keyframe')).toBe('缺少主首帧')
|
|
expect(issueLabel('stale_prompt')).toBe('视频提示词需要更新')
|
|
expect(issueLabel('provider_input_recovery')).toBe('视频模型输入需要处理')
|
|
expect(issueLabel('invalid_generation_spec')).toBe('生成规格不完整')
|
|
expect(issueLabel('missing_identity_anchor')).toBe('缺少演员母版')
|
|
expect(issueLabel('identity_unlocked')).toBe('演员身份未锁定')
|
|
expect(productionStatusLabel('in_progress')).toBe('任务进行中')
|
|
expect(productionStatusLabel('unknown')).toBe('unknown')
|
|
})
|
|
|
|
it('视频地址与图片使用相同的安全协议限制', () => {
|
|
expect(mediaAssetUrl('/storage/videos/a.mp4')).toContain('/storage/videos/a.mp4')
|
|
expect(mediaAssetUrl('https://cdn.example.com/a.mp4')).toBe('https://cdn.example.com/a.mp4')
|
|
expect(mediaAssetUrl('javascript:alert(1)')).toBeNull()
|
|
expect(mediaAssetUrl('/storage/../admin')).toBeNull()
|
|
})
|
|
|
|
it('项目接口传递 force、Provider 与并发,视频创建不冒充同步完成', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
|
const path = String(url)
|
|
if (path.includes('/readiness'))
|
|
return new Response(
|
|
JSON.stringify({
|
|
data: {
|
|
total: 1,
|
|
ready: 1,
|
|
skipped: 0,
|
|
inProgress: 0,
|
|
blocked: 0,
|
|
missingPrompt: 0,
|
|
missingKeyframe: 0,
|
|
missingReference: 0,
|
|
items: []
|
|
}
|
|
})
|
|
)
|
|
return new Response(
|
|
JSON.stringify({
|
|
data: {
|
|
total: 1,
|
|
targetCount: 1,
|
|
created: 1,
|
|
skipped: 0,
|
|
readiness: {},
|
|
failed: 0,
|
|
failures: []
|
|
}
|
|
})
|
|
)
|
|
})
|
|
vi.stubGlobal('fetch', fetcher)
|
|
await productionApi.videoReadiness('project/1', true)
|
|
await productionApi.generateQualityVideos('project/1', {
|
|
concurrency: 3,
|
|
force: true,
|
|
limit: 2,
|
|
maxRepairAttempts: 2
|
|
})
|
|
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/videos/readiness?force=true')
|
|
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/videos/generate-quality')
|
|
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
|
|
concurrency: 3,
|
|
force: true,
|
|
limit: 2,
|
|
maxRepairAttempts: 2
|
|
})
|
|
})
|
|
|
|
it('首个首帧默认设主图,已有主图时默认只新增候选,并要求费用确认', async () => {
|
|
wrapper = mount(KeyframeDialog, {
|
|
attachTo: document.body,
|
|
props: { open: true, shotId: 'shot-1', shotTitle: '开场', keyframes: [], disabled: false }
|
|
})
|
|
await flushPromises()
|
|
expect(button('确认生成首帧').disabled).toBe(true)
|
|
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
|
|
await flushPromises()
|
|
button('确认生成首帧').click()
|
|
await flushPromises()
|
|
expect(wrapper.emitted('generate')).toEqual([[{ setPrimary: true }]])
|
|
|
|
await wrapper.setProps({ open: false })
|
|
await flushPromises()
|
|
await wrapper.setProps({ open: true, shotId: 'shot-2', keyframes: [keyframeFixture({ shotId: 'shot-2' })] })
|
|
await flushPromises()
|
|
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
|
|
await flushPromises()
|
|
button('确认生成首帧').click()
|
|
await flushPromises()
|
|
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
|
|
})
|
|
})
|