feat: 收口组件样式并调整移动端侧栏
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectContextKey } from '@/features/projects/context'
|
||||
import { testProjectContext } from '@/testing/project-context'
|
||||
import { getOperation } from '@/features/workflows/operations'
|
||||
import { formFixture } from '@/features/subject-images/testing/fixtures'
|
||||
import { directionsResult } from '@/features/storyboard/testing/fixtures'
|
||||
import { checkPipeline, useAdvancedProduction } from '@/features/production/useAdvancedProduction'
|
||||
import { useProduction } from '@/features/production/useProduction'
|
||||
import { getProductionSession } from '@/features/production/model'
|
||||
import AdvancedProduction from '@/features/production/components/AdvancedProduction.vue'
|
||||
import { expandSections } from '@/testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
/** 从实际挂载的确认弹窗查找操作按钮。 */
|
||||
function button(label: string) {
|
||||
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
|
||||
}
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
for (const id of ['capability-test', 'new-project']) {
|
||||
Object.assign(getOperation(id), { pending: false, error: '', notice: '', label: '' })
|
||||
Object.assign(getProductionSession(id), { receipt: null, pipelineReceipt: null })
|
||||
}
|
||||
})
|
||||
|
||||
/** 所有预检均是模拟 GET;生成接口只记录契约,不调用真实 Provider。 */
|
||||
function server() {
|
||||
const context = testProjectContext()
|
||||
const form = formFixture('capability-test')
|
||||
const readiness = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
inProgress: 0,
|
||||
stalePrimaryKeyframe: 0,
|
||||
items: [
|
||||
{
|
||||
shotId: 'shot-1',
|
||||
shotNo: 1,
|
||||
episodeNo: 1,
|
||||
beatNo: 1,
|
||||
status: 'ready',
|
||||
issues: [] as { code: string; reason: string }[],
|
||||
primaryKeyframeId: 'keyframe-1',
|
||||
primaryKeyframeStale: false
|
||||
}
|
||||
]
|
||||
}
|
||||
const receipt = {
|
||||
projectId: 'capability-test',
|
||||
completed: true,
|
||||
needsManualReview: false,
|
||||
stopReason: '',
|
||||
errors: ['一个提示词未生成'],
|
||||
videoGenerationResult: { total: 1, created: 1, skipped: 0, failed: 0 }
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
let data: unknown = context.project.value
|
||||
if (path.endsWith('/checkpoints')) data = context.checkpoints.value
|
||||
else if (path.endsWith('/subject-forms')) data = [form]
|
||||
else if (path.includes('/readiness')) data = readiness
|
||||
else if (path.includes('/storyboard-directions')) data = directionsResult('capability-test')
|
||||
else if (path.endsWith('/videos/status'))
|
||||
data = {
|
||||
total: 1,
|
||||
completed: 0,
|
||||
queued: 0,
|
||||
running: 0,
|
||||
pending: 0,
|
||||
failed: 0,
|
||||
cancelled: 0,
|
||||
notStarted: 1,
|
||||
items: []
|
||||
}
|
||||
else if (init?.method === 'POST')
|
||||
data = path.endsWith('/production/start')
|
||||
? receipt
|
||||
: { total: 1, targetCount: 1, generated: 1, skipped: 0, blocked: 0, failed: 0, failures: [] }
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
return {
|
||||
context,
|
||||
form,
|
||||
readiness,
|
||||
receipt,
|
||||
fetcher,
|
||||
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
}
|
||||
}
|
||||
|
||||
async function advanced() {
|
||||
const data = server()
|
||||
let service!: ReturnType<typeof useAdvancedProduction>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useAdvancedProduction()
|
||||
return () => null
|
||||
}
|
||||
}),
|
||||
{ global: { provide: { [projectContextKey as symbol]: data.context } } }
|
||||
)
|
||||
await flushPromises()
|
||||
return { ...data, service }
|
||||
}
|
||||
|
||||
describe('高级串联生产的安全边界', () => {
|
||||
it('挂载不查询或生成,通过预检仍需显式启动,再次预检后提交固定 Provider', async () => {
|
||||
const { service, fetcher, posts } = await advanced()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
await service.preflight()
|
||||
expect(service.check.value?.issues).toEqual([])
|
||||
expect(posts()).toHaveLength(0)
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(1)
|
||||
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/production/start')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({})
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([url]) => String(url).includes('/keyframes/readiness?force=true'))
|
||||
).toHaveLength(2)
|
||||
expect(service.session.value.pipelineReceipt?.errors).toEqual(['一个提示词未生成'])
|
||||
expect(service.check.value).toBeNull()
|
||||
})
|
||||
it('预检通过后主首帧变旧,确认时再次预检会拦截,不提交', async () => {
|
||||
const { service, readiness, posts } = await advanced()
|
||||
await service.preflight()
|
||||
readiness.items[0]!.primaryKeyframeStale = true
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
expect(service.check.value?.issues.join()).toContain('有效主首帧')
|
||||
expect(getOperation('capability-test').error).toContain('条件变化')
|
||||
})
|
||||
it('后台活动视频、缺失主图与剧本未完成均阻止预检通过', async () => {
|
||||
const { context, form, readiness } = server()
|
||||
context.data.value!.project.status = 'need_review'
|
||||
form.images = []
|
||||
readiness.inProgress = 1
|
||||
const result = await checkPipeline('capability-test')
|
||||
expect(result.issues.join()).toContain('剧本尚未完成')
|
||||
expect(result.issues.join()).toContain('所有形态主图')
|
||||
expect(result.issues.join()).toContain('活动视频任务')
|
||||
})
|
||||
it('已有视频的 skipped 也不能掩盖缺失首帧,只有缺少提示词允许本流程补齐', async () => {
|
||||
const { readiness } = server()
|
||||
readiness.items[0]!.status = 'skipped'
|
||||
readiness.items[0]!.issues = [{ code: 'missing_keyframe', reason: '无首帧' }]
|
||||
const result = await checkPipeline('capability-test')
|
||||
expect(result.issues.join()).toContain('视频前置检查未通过')
|
||||
})
|
||||
it('项目锁和未完成剧本下不启动,切项目清空旧预检', async () => {
|
||||
const { service, context, posts } = await advanced()
|
||||
await service.preflight()
|
||||
getOperation('capability-test').pending = true
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
getOperation('capability-test').pending = false
|
||||
context.data.value!.project.status = 'failed'
|
||||
await service.start()
|
||||
expect(posts()).toHaveLength(0)
|
||||
context.data.value!.project.id = 'new-project'
|
||||
await flushPromises()
|
||||
expect(service.check.value).toBeNull()
|
||||
})
|
||||
it('已提交的长请求只写回原项目的回执,不污染新项目', async () => {
|
||||
const { service, context, fetcher, receipt } = await advanced()
|
||||
await service.preflight()
|
||||
const original = fetcher.getMockImplementation()!
|
||||
let finish!: (value: Response) => void
|
||||
fetcher.mockImplementation((url, init) =>
|
||||
String(url).endsWith('/production/start')
|
||||
? new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
: original(url, init)
|
||||
)
|
||||
const pending = service.start()
|
||||
await flushPromises()
|
||||
context.data.value!.project.id = 'new-project'
|
||||
await flushPromises()
|
||||
finish(new Response(JSON.stringify({ data: receipt })))
|
||||
await pending
|
||||
expect(service.session.value.pipelineReceipt).toBeNull()
|
||||
expect(getProductionSession('capability-test').pipelineReceipt?.projectId).toBe('capability-test')
|
||||
})
|
||||
it('回执项目不匹配时不发布成功回执', async () => {
|
||||
const { service, receipt } = await advanced()
|
||||
await service.preflight()
|
||||
receipt.projectId = 'wrong'
|
||||
await service.start()
|
||||
expect(service.session.value.pipelineReceipt).toBeNull()
|
||||
expect(getOperation('capability-test').error).toContain('回执项目不匹配')
|
||||
})
|
||||
it('界面区分流程返回与视频完成,显示部分错误,启动需要费用确认', async () => {
|
||||
const { context, receipt, posts } = server()
|
||||
wrapper = mount(AdvancedProduction, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: context } }
|
||||
})
|
||||
await flushPromises()
|
||||
await expandSections()
|
||||
expect(button('启动串联生产').disabled).toBe(true)
|
||||
button('检查串联生产条件').click()
|
||||
await flushPromises()
|
||||
button('启动串联生产').click()
|
||||
await flushPromises()
|
||||
expect(button('确认启动串联生产').disabled).toBe(true)
|
||||
expect(posts()).toHaveLength(0)
|
||||
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
button('确认启动串联生产').click()
|
||||
await flushPromises()
|
||||
expect(posts()).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('流程返回不等于成片完成')
|
||||
expect(document.body.textContent).toContain(receipt.errors[0])
|
||||
expect(document.body.textContent).toContain('已提交 1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('首帧批量参数', () => {
|
||||
it('只向首帧传上限和成对尺寸,默认范围仍是当前剧集', async () => {
|
||||
const { context, posts } = server()
|
||||
let service!: ReturnType<typeof useProduction>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useProduction()
|
||||
return () => null
|
||||
}
|
||||
}),
|
||||
{ global: { provide: { [projectContextKey as symbol]: context } } }
|
||||
)
|
||||
await flushPromises()
|
||||
service.keyframeLimit.value = 2
|
||||
service.keyframeWidth.value = 2048
|
||||
await service.run('keyframes')
|
||||
expect(posts()).toHaveLength(0)
|
||||
service.keyframeHeight.value = 2048
|
||||
await service.run('keyframes')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
||||
concurrency: 2,
|
||||
force: false,
|
||||
episodeNo: 1,
|
||||
limit: 2,
|
||||
width: 2048,
|
||||
height: 2048
|
||||
})
|
||||
service.keyframeLimit.value = -1
|
||||
await service.run('keyframes')
|
||||
expect(posts()).toHaveLength(1)
|
||||
await service.run('prompts')
|
||||
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ concurrency: 2, force: false })
|
||||
service.keyframeLimit.value = ''
|
||||
service.keyframeWidth.value = ''
|
||||
service.keyframeHeight.value = ''
|
||||
service.force.value = true
|
||||
await flushPromises()
|
||||
await service.run('keyframes')
|
||||
expect(JSON.parse(String(posts()[2]?.[1]?.body))).toEqual({ concurrency: 2, force: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
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('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.generateVideos('project/1', { concurrency: 3, force: true })
|
||||
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')
|
||||
expect(JSON.parse(String(fetcher.mock.calls[1]?.[1]?.body))).toEqual({
|
||||
concurrency: 3,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
|
||||
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()
|
||||
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,515 @@
|
||||
import { defineComponent, reactive, ref } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NCheckbox, NInputNumber, NRadioGroup } from 'naive-ui'
|
||||
import { testProjectContext } from '@/testing/project-context'
|
||||
import { productionApi } from '@/features/production/api'
|
||||
import { qualityApi } from '@/features/production/quality-api'
|
||||
import {
|
||||
allowedTextLines,
|
||||
qualityKey,
|
||||
qualitySession,
|
||||
qualityTargets,
|
||||
savedVideoValidation,
|
||||
videoRepairInfo,
|
||||
validQualityInput
|
||||
} from '@/features/production/quality'
|
||||
import type { KeyframeReadiness } from '@/features/production/types'
|
||||
import type { QualityInput, QualityTarget } from '@/features/production/quality.types'
|
||||
import { useQuality } from '@/features/production/useQuality'
|
||||
import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
|
||||
import QualityDialog from '@/features/production/components/QualityDialog.vue'
|
||||
import QualityResult from '@/features/production/components/QualityResult.vue'
|
||||
import ModelCapabilities from '@/features/production/components/ModelCapabilities.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
let sequence = 0
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 模拟统一配置后的真实契约,测试绝不调用真实图片或视觉模型。 */
|
||||
function server() {
|
||||
const projectId = `quality-test-${sequence++}`
|
||||
const project = testProjectContext(projectId).project.value!
|
||||
const validation = {
|
||||
passed: true,
|
||||
summary: '身份与造型一致',
|
||||
subjects: [],
|
||||
subjectCountConsistent: true,
|
||||
unauthorizedText: { detected: false, texts: [] },
|
||||
issues: []
|
||||
}
|
||||
const keyframe = keyframeFixture()
|
||||
const video = videoFixture()
|
||||
const readiness = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
stalePrimaryKeyframe: 0,
|
||||
missingVisualStyle: 0,
|
||||
missingIdentity: 0,
|
||||
missingIdentityAnchor: 0,
|
||||
identityUnlocked: 0,
|
||||
invalidGenerationSpec: 0,
|
||||
missingReference: 0,
|
||||
items: [{ shotId: 'shot-1', shotNo: 1, beatNo: 1, episodeNo: 1, status: 'ready', issues: [] }]
|
||||
} as KeyframeReadiness
|
||||
const capability = {
|
||||
provider: 'qwen-image',
|
||||
referenceCount: 3,
|
||||
maxReferenceImages: 3,
|
||||
valid: true,
|
||||
message: '参考图超限'
|
||||
}
|
||||
const keyframeResult = {
|
||||
...validation,
|
||||
shotId: 'shot-1',
|
||||
keyframeId: 'keyframe-1',
|
||||
validationTaskId: 'validation-1',
|
||||
isPrimary: true,
|
||||
imageUrl: keyframe.imageUrl!
|
||||
}
|
||||
const attempt = {
|
||||
attempt: 0,
|
||||
keyframeId: 'keyframe-1',
|
||||
validationTaskId: 'validation-1',
|
||||
passed: true,
|
||||
issues: [],
|
||||
validationDurationMs: 10
|
||||
}
|
||||
const repair = {
|
||||
shotId: 'shot-1',
|
||||
initialKeyframeId: 'keyframe-1',
|
||||
finalKeyframeId: 'keyframe-1',
|
||||
passed: true,
|
||||
repaired: false,
|
||||
primaryChanged: false,
|
||||
repairAttempts: 0,
|
||||
maxRepairAttempts: 1,
|
||||
attempts: [attempt]
|
||||
}
|
||||
const batch = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
selected: 1,
|
||||
targetCount: 1,
|
||||
passed: 1,
|
||||
repairFailed: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
stalePrimaryKeyframe: 0,
|
||||
missingReference: 0,
|
||||
results: [{ shotId: 'shot-1', success: true, status: 'passed' as const }]
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, options) => {
|
||||
const path = String(url)
|
||||
let data: unknown
|
||||
if (options?.method === 'POST') {
|
||||
if (path.endsWith('/generate-quality')) data = batch
|
||||
else if (path.includes('/videos/') && path.endsWith('/repair'))
|
||||
data = {
|
||||
shotId: 'shot-1',
|
||||
sourceVideoId: 'video-1',
|
||||
repairAttempt: 1,
|
||||
maxRepairAttempts: 2,
|
||||
repairInstructions: ['修复人物漂移'],
|
||||
allowedTexts: [],
|
||||
candidate: videoFixture({
|
||||
id: 'video-repair-1',
|
||||
status: 'queued',
|
||||
isPrimary: false,
|
||||
videoUrl: null,
|
||||
rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
|
||||
})
|
||||
}
|
||||
else if (path.endsWith('/repair')) data = repair
|
||||
else if (path.includes('/videos/'))
|
||||
data = {
|
||||
...validation,
|
||||
shotId: 'shot-1',
|
||||
videoId: 'video-1',
|
||||
validatedAt: '2026-09-03',
|
||||
isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
|
||||
videoUrl: video.videoUrl,
|
||||
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
|
||||
allowedTexts: []
|
||||
}
|
||||
else data = keyframeResult
|
||||
} else if (path.endsWith(`/projects/${projectId}`)) data = project
|
||||
else if (path.includes('/readiness')) data = readiness
|
||||
else if (path.endsWith('/keyframes')) data = [keyframe]
|
||||
else if (path.endsWith('/videos')) data = [video]
|
||||
else if (path.endsWith('/keyframe-provider-capability')) data = capability
|
||||
else if (path.endsWith('/image-providers/capabilities'))
|
||||
data = [
|
||||
{
|
||||
provider: 'qwen-image',
|
||||
active: true,
|
||||
capabilities: { references: { supported: true, maxReferenceImages: 3 } }
|
||||
}
|
||||
]
|
||||
else throw new Error('意外接口:' + path)
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
return {
|
||||
projectId,
|
||||
project,
|
||||
validation,
|
||||
keyframe,
|
||||
video,
|
||||
readiness,
|
||||
capability,
|
||||
keyframeResult,
|
||||
repair,
|
||||
batch,
|
||||
fetcher,
|
||||
posts: () => fetcher.mock.calls.filter(([, options]) => options?.method === 'POST')
|
||||
}
|
||||
}
|
||||
|
||||
/** 统一默认小批次,零修复和自定义允许文字均可单独覆写。 */
|
||||
function input(patch: Partial<QualityInput> = {}): QualityInput {
|
||||
return { concurrency: 1, limit: 1, episodeNo: 1, maxRepairAttempts: 1, force: false, allowedTexts: [], ...patch }
|
||||
}
|
||||
|
||||
/** 通过实际挂载按钮检验费用确认,避免绕过禁用状态。 */
|
||||
function validationButton() {
|
||||
return [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
item => item.textContent === '开始视觉校验'
|
||||
)!
|
||||
}
|
||||
|
||||
function setup(target: QualityTarget = { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '镜头一' }) {
|
||||
const data = server()
|
||||
const props = reactive({ projectId: data.projectId, target: target as QualityTarget | null, disabled: false })
|
||||
const visible = ref(true)
|
||||
const changed = vi.fn<() => void>()
|
||||
let service!: ReturnType<typeof useQuality>
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useQuality(props, () => visible.value, changed)
|
||||
return () => null
|
||||
}
|
||||
})
|
||||
)
|
||||
return { ...data, props, visible, changed, service }
|
||||
}
|
||||
|
||||
describe('视觉质量契约与付费边界', () => {
|
||||
it('打开面板不发模型请求,确认后仅校验指定首帧', async () => {
|
||||
const data = setup()
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
await data.service.run('validate', input({ allowedTexts: ['记忆当铺'] }))
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/keyframes/keyframe-1/validate')
|
||||
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ allowedTexts: ['记忆当铺'] })
|
||||
expect(data.service.session.value.receipt).toMatchObject({ kind: 'keyframe', result: { passed: true } })
|
||||
})
|
||||
|
||||
it.each(['generating', 'failed', 'need_review'] as const)('后端项目状态为 %s 时不发付费请求', async status => {
|
||||
const data = setup()
|
||||
data.project.status = status
|
||||
await data.service.run('validate', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.service.session.value.error).toContain('剧本未完成')
|
||||
})
|
||||
|
||||
it('目标不属于项目时不查询目标素材,不生成', async () => {
|
||||
const data = setup({ kind: 'keyframe', shotId: 'foreign-shot', assetId: 'foreign-image', title: '外部' })
|
||||
await data.service.run('validate', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot'))).toBe(false)
|
||||
})
|
||||
|
||||
it('畸形视觉结果不能当作校验通过,也不会自动重试', async () => {
|
||||
const data = setup()
|
||||
vi.spyOn(qualityApi, 'validateKeyframe').mockResolvedValueOnce({
|
||||
...data.keyframeResult,
|
||||
passed: undefined
|
||||
} as never)
|
||||
await data.service.run('validate', input())
|
||||
expect(data.service.session.value.receipt).toBeNull()
|
||||
expect(data.service.session.value.error).toContain('不完整')
|
||||
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('0 次修复保留语义;不会额外调用普通生图或切换主图接口', async () => {
|
||||
const data = setup()
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.posts()[0]?.[0]).toContain('/repair')
|
||||
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 0, allowedTexts: [] })
|
||||
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||
expect(data.service.session.value.receipt).toMatchObject({ kind: 'repair', result: { primaryChanged: false } })
|
||||
})
|
||||
|
||||
it('模型参考图能力不足时阻止修复与质量批次', async () => {
|
||||
const data = setup()
|
||||
data.capability.valid = false
|
||||
await data.service.run('repair', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.service.session.value.error).toContain('参考图超限')
|
||||
data.props.target = { kind: 'batch', title: '第一集', episodeNo: 1 }
|
||||
await data.service.run('batch', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('视频需完成并有有效时长,校验不自动切换主视频', async () => {
|
||||
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||
data.video.durationSeconds = null
|
||||
await data.service.run('validate', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
data.video.durationSeconds = 5
|
||||
await data.service.run('validate', input())
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/validate')
|
||||
expect(data.service.session.value.receipt).toMatchObject({
|
||||
kind: 'video',
|
||||
result: { sampleFrames: [{ timeSeconds: 2 }] }
|
||||
})
|
||||
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||
})
|
||||
|
||||
it('批量范围、上限、尺寸原样传递,不发送 Provider;部分失败仍显示待处理', async () => {
|
||||
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
|
||||
Object.assign(data.batch, {
|
||||
passed: 0,
|
||||
repairFailed: 1,
|
||||
results: [{ shotId: 'shot-1', success: false, status: 'repair_failed', error: '人物不一致' }]
|
||||
})
|
||||
await data.service.run('batch', input({ width: 1536, height: 1024 }))
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual(input({ width: 1536, height: 1024 }))
|
||||
const receipt = data.service.session.value.receipt!
|
||||
wrapper!.unmount()
|
||||
wrapper = mount(QualityResult, { props: { receipt } })
|
||||
expect(wrapper.text()).toContain('仍需处理')
|
||||
expect(wrapper.text()).toContain('人物不一致')
|
||||
await wrapper
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '定位镜头')!
|
||||
.trigger('click')
|
||||
expect(wrapper.emitted('locate')).toEqual([['shot-1']])
|
||||
})
|
||||
|
||||
it('过期优先级与后端一致,当前集无过期项时不误补其他镜头', async () => {
|
||||
const data = setup({ kind: 'batch', episodeNo: 1, title: '第一集' })
|
||||
data.readiness.stalePrimaryKeyframe = 1
|
||||
expect(qualityTargets(data.readiness, input())).toHaveLength(0)
|
||||
await data.service.run('batch', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.service.session.value.error).toContain('切换剧集')
|
||||
expect(qualityTargets(data.readiness, input({ force: true }))).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('错误输入与未完成素材不提交,已禁用操作也不能绕过', async () => {
|
||||
const data = setup()
|
||||
for (const values of [{ limit: 0 }, { concurrency: 1.5 }, { maxRepairAttempts: -1 }, { width: 100 }])
|
||||
await data.service.run('repair', input(values))
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
data.keyframe.status = 'generating'
|
||||
await data.service.run('repair', input())
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
data.props.disabled = true
|
||||
const count = data.fetcher.mock.calls.length
|
||||
await data.service.run('validate', input())
|
||||
expect(data.fetcher).toHaveBeenCalledTimes(count)
|
||||
})
|
||||
|
||||
it('预检中关闭或换项目不发 POST;已提交的迟到回执只写回原目标', async () => {
|
||||
const data = setup()
|
||||
let finishCheck!: (value: KeyframeReadiness) => void
|
||||
vi.spyOn(productionApi, 'keyframeReadiness').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finishCheck = resolve
|
||||
})
|
||||
)
|
||||
const pendingCheck = data.service.run('validate', input())
|
||||
await flushPromises()
|
||||
data.visible.value = false
|
||||
finishCheck(data.readiness)
|
||||
await pendingCheck
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
data.visible.value = true
|
||||
let finish!: (value: typeof data.keyframeResult) => void
|
||||
vi.spyOn(qualityApi, 'validateKeyframe').mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
const originalKey = data.service.key.value
|
||||
const pending = data.service.run('validate', input())
|
||||
await flushPromises()
|
||||
await data.service.run('validate', input())
|
||||
expect(qualityApi.validateKeyframe).toHaveBeenCalledTimes(1)
|
||||
data.props.projectId = 'different-project'
|
||||
finish(data.keyframeResult)
|
||||
await pending
|
||||
expect(qualitySession(originalKey).receipt).toMatchObject({ kind: 'keyframe' })
|
||||
expect(data.service.session.value.receipt).toBeNull()
|
||||
})
|
||||
|
||||
it('API 对正式 ID 编码,视觉与质量长请求只发一次', async () => {
|
||||
const data = server()
|
||||
await qualityApi.validateKeyframe('shot/a', 'asset/b', [])
|
||||
await qualityApi.repair('shot/a', 'asset/b', { maxRepairAttempts: 1 })
|
||||
await qualityApi.validateVideo('shot/a', 'video/b', [])
|
||||
expect(data.posts().map(([url]) => url)).toEqual([
|
||||
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/validate',
|
||||
'/api/storyboard-shots/shot%2Fa/keyframes/asset%2Fb/repair',
|
||||
'/api/storyboard-shots/shot%2Fa/videos/video%2Fb/validate'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('质量面板渐进展示', () => {
|
||||
it('默认仅校验,未确认时不可提交,打开或更改参数不产生费用', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(QualityDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
projectId: data.projectId,
|
||||
target: { kind: 'keyframe', shotId: 'shot-1', assetId: 'keyframe-1', title: '测试镜头' },
|
||||
open: true,
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
expect(validationButton().disabled).toBe(true)
|
||||
expect(document.body.textContent).toContain('更多参数与模型限制')
|
||||
expect(document.querySelector('[aria-label="额外允许的画面文字"]')).toBeNull()
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
|
||||
await flushPromises()
|
||||
expect(validationButton().disabled).toBe(false)
|
||||
validationButton().click()
|
||||
await flushPromises()
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(document.body.textContent).toContain('视觉校验通过')
|
||||
})
|
||||
|
||||
it('批量默认当前集一镜,调整次数撤销费用确认', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(QualityDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
projectId: data.projectId,
|
||||
target: { kind: 'batch', episodeNo: 2, title: '第二集' },
|
||||
open: true,
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('仅第 2 集,最多 1 镜')
|
||||
expect(document.body.textContent).toContain('最多调用 2 次生图、2 次视觉校验')
|
||||
wrapper.findAllComponents(NCheckbox).at(-1)!.vm.$emit('update:checked', true)
|
||||
await flushPromises()
|
||||
wrapper.findAllComponents(NInputNumber)[0]!.vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
const submit = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
|
||||
item => item.textContent === '开始质量生成'
|
||||
)!
|
||||
expect(submit.disabled).toBe(true)
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('模型能力只读按需查询,明确当前启用模型及参考图上限', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(ModelCapabilities, { props: { shotId: 'shot-1' } })
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
await wrapper.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('qwen-image · 当前启用')
|
||||
expect(wrapper.text()).toContain('最多 3 张参考图')
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('视频历史读取不触发视觉模型,畸形历史不会伪造通过', () => {
|
||||
const data = server()
|
||||
expect(savedVideoValidation(JSON.stringify({ videoValidation: data.validation }))?.passed).toBe(true)
|
||||
expect(savedVideoValidation('{broken')).toBeNull()
|
||||
expect(savedVideoValidation('{"videoValidation":{"passed":true}}')).toBeNull()
|
||||
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
|
||||
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
|
||||
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
|
||||
qualityKey('b', { kind: 'batch', episodeNo: 1, title: '' })
|
||||
)
|
||||
expect(data.fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('视频修复候选与复检晋升', () => {
|
||||
it('只允许校验未通过的视频创建一个候选,不自动再次校验', async () => {
|
||||
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
data.video.rawJson = JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.posts()[0]?.[0]).toBe('/api/storyboard-shots/shot-1/videos/video-1/repair')
|
||||
expect(JSON.parse(String(data.posts()[0]?.[1]?.body))).toEqual({ maxRepairAttempts: 2 })
|
||||
expect(data.service.session.value.receipt).toMatchObject({
|
||||
kind: 'video-repair',
|
||||
result: { candidate: { status: 'queued', isPrimary: false } }
|
||||
})
|
||||
})
|
||||
|
||||
it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
|
||||
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' })
|
||||
data.video.rawJson = JSON.stringify({
|
||||
videoValidation: { ...data.validation, passed: false },
|
||||
repair: { attempt: 2 }
|
||||
})
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
expect(data.service.session.value.error).toContain('次数上限')
|
||||
})
|
||||
|
||||
it('复检通过的修复候选准确显示自动晋升,前端不再另发主视频 PUT', async () => {
|
||||
const data = setup({ kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '修复候选' })
|
||||
data.video.rawJson = JSON.stringify({ repair: { attempt: 1, sourceVideoId: 'source' } })
|
||||
data.video.isPrimary = false
|
||||
await data.service.run('validate', input())
|
||||
const receipt = data.service.session.value.receipt!
|
||||
expect(receipt).toMatchObject({ kind: 'video', promoted: true, result: { isPrimary: true } })
|
||||
expect(data.posts()).toHaveLength(1)
|
||||
expect(data.fetcher.mock.calls.some(([, options]) => options?.method === 'PUT')).toBe(false)
|
||||
wrapper!.unmount()
|
||||
wrapper = mount(QualityResult, { props: { receipt } })
|
||||
expect(wrapper.text()).toContain('后端已将其设为主视频')
|
||||
})
|
||||
|
||||
it('视频修复面板明确一次一任务及复检后替换,不展示生图次数', async () => {
|
||||
const data = server()
|
||||
wrapper = mount(QualityDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
projectId: data.projectId,
|
||||
target: { kind: 'video', shotId: 'shot-1', assetId: 'video-1', title: '视频' },
|
||||
open: true,
|
||||
disabled: false,
|
||||
savedRawJson: JSON.stringify({ videoValidation: { ...data.validation, passed: false } })
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('修复候选复检通过后会自动设为主视频')
|
||||
wrapper.getComponent(NRadioGroup).vm.$emit('update:value', 'repair')
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('本次最多提交 1 个视频生成任务')
|
||||
expect(document.body.textContent).not.toContain('次生图')
|
||||
expect(document.body.textContent).toContain('修复链次数上限')
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user