feat: 同步精简接口并统一表单校验
This commit is contained in:
@@ -111,8 +111,7 @@ function mountPage(records = [checkpoint()], episodes = 1): ReturnType<typeof us
|
||||
updatedAt: '',
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: [],
|
||||
|
||||
episodes: episodes ? [{ episode: 1, title: '第一集', content: '正文' }] : []
|
||||
}
|
||||
const data = ref({ project, checkpoints: records })
|
||||
@@ -149,10 +148,10 @@ describe('拆解页内容滚动', () => {
|
||||
await wrapper!.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
const config = drawerPanel().get('.breakdown-config')
|
||||
expect(config.get('label[for="group-size"]').text()).toBe('每组集数')
|
||||
expect(config.get('label[for="group-size"]').text()).toContain('每组集数')
|
||||
expect(config.get<HTMLInputElement>('#group-size').element.value).toBe('3')
|
||||
expect(config.get('.breakdown-group-unit').text()).toBe('集 / 组')
|
||||
expect(config.get('fieldset > legend').text()).toBe('抽取模块')
|
||||
expect(config.get('.breakdown-modules-field .n-form-item-label').text()).toContain('抽取模块')
|
||||
const options = config.findAll('.breakdown-module-option')
|
||||
expect(options).toHaveLength(3)
|
||||
const preview = config.get<HTMLButtonElement>('.breakdown-preview-button')
|
||||
@@ -165,8 +164,10 @@ describe('拆解页内容滚动', () => {
|
||||
expect(description.text()).not.toBe('')
|
||||
await checkbox.trigger('click')
|
||||
}
|
||||
expect(preview.element.disabled).toBe(true)
|
||||
expect(drawerPanel().text()).toContain('至少选择一个抽取模块')
|
||||
await preview.trigger('click')
|
||||
await flushPromises()
|
||||
expect(config.get('.n-form-item-feedback--error').text()).toContain('至少选择一个抽取模块')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await options[1]!.get('[role="checkbox"]').trigger('click')
|
||||
expect(preview.element.disabled).toBe(false)
|
||||
provided.data.value!.project.status = 'generating'
|
||||
@@ -179,9 +180,7 @@ describe('拆解页内容滚动', () => {
|
||||
// DOM 环境不计算坐标,检查统一标题偏移、34px 控件行和容器断点契约。
|
||||
const css = readAllStyles()
|
||||
expect(css).toMatch(/\.breakdown-settings-panel\s*\{\s*container:\s*breakdown-settings \/ inline-size;/)
|
||||
expect(css).toMatch(/\.breakdown-config\s*\{[^}]*--breakdown-label-offset:\s*28px;/)
|
||||
expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*margin(?:-bottom)?:\s*(?:0 0 )?8px/)
|
||||
expect(css).toMatch(/\.breakdown-config \.field-label\s*\{[^}]*line-height:\s*20px/)
|
||||
expect(css).toMatch(/\.breakdown-config\s*\{[^}]*--breakdown-label-offset:\s*29px;/)
|
||||
expect(css).toMatch(/\.breakdown-group-unit\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
|
||||
expect(css).toMatch(
|
||||
/\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;/
|
||||
|
||||
@@ -50,6 +50,8 @@ describe('镜头生产数据契约', () => {
|
||||
|
||||
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('演员身份未锁定')
|
||||
@@ -135,6 +137,7 @@ describe('镜头生产数据契约', () => {
|
||||
document.querySelector<HTMLInputElement>('#confirm-keyframe-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成首帧').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')?.at(-1)).toEqual([{ setPrimary: false }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import QualityResult from '@/features/production/components/QualityResult.vue'
|
||||
import { expandSections } from '@/testing/naive'
|
||||
import ShotProductionAssets from '@/features/production/components/ShotProductionAssets.vue'
|
||||
import { primaryKeyframe } from '@/features/production/model'
|
||||
import { qualityKey, qualitySession, sessionVideoValidation } from '@/features/production/quality'
|
||||
import { keyframeFixture, videoFixture } from '@/features/production/testing/fixtures'
|
||||
import { storyboardApi } from '@/features/storyboard/api'
|
||||
import { hasRunningWorkflow } from '@/features/workflows/selectors'
|
||||
import type { DesignedShot } from '@/features/storyboard/types'
|
||||
import type { VideoValidation } from '@/features/production/quality.types'
|
||||
import type { Checkpoint } from '@/features/workflows/types'
|
||||
|
||||
const shot: DesignedShot = {
|
||||
shotId: 'shot-1',
|
||||
shotNo: 1,
|
||||
beatNo: 1,
|
||||
title: '测试镜头',
|
||||
description: '',
|
||||
visualFocus: '',
|
||||
direction: null,
|
||||
visualState: null
|
||||
}
|
||||
const validation: VideoValidation = {
|
||||
shotId: 'shot-1',
|
||||
videoId: 'video-1',
|
||||
videoUrl: '/storage/videos/video-1.mp4',
|
||||
isPrimary: false,
|
||||
passed: true,
|
||||
summary: '通过',
|
||||
subjects: [],
|
||||
subjectCountConsistent: true,
|
||||
unauthorizedText: { detected: false, texts: [] },
|
||||
issues: [],
|
||||
sampleFrames: [],
|
||||
allowedTexts: [],
|
||||
validatedAt: '2026-09-10T00:00:00Z'
|
||||
}
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 构造带完整执行字段的后端 checkpoint。 */
|
||||
const record = (workflowName: string, day: number, status: 'running' | 'completed'): Checkpoint => ({
|
||||
checkpointId: `${workflowName}-${day}`,
|
||||
workflowName,
|
||||
createdAt: `2026-09-0${day}T00:00:00Z`,
|
||||
state: {
|
||||
workflowExecution: {
|
||||
status,
|
||||
executionId: `${workflowName}-${day}`,
|
||||
startedAt: `2026-09-0${day}T00:00:00Z`
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe('精简公开资产回归', () => {
|
||||
it('无内部字段的视频可播放,未知质量不得直接晋升;真实校验结果按项目隔离', async () => {
|
||||
const projectId = 'public-video-contract'
|
||||
const video = videoFixture({ isPrimary: false })
|
||||
expect(video).not.toHaveProperty('rawJson')
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async url =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).endsWith('/videos') ? [video] : []
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const wrapper = mount(ShotProductionAssets, { props: { projectId, shot, disabled: false } })
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(wrapper.find('video').exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('自动校验中')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(false)
|
||||
const session = qualitySession(
|
||||
qualityKey(projectId, { kind: 'video', shotId: shot.shotId, assetId: video.id, title: '' })
|
||||
)
|
||||
session.videoValidation = validation
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(true)
|
||||
expect(sessionVideoValidation('other-project', shot.shotId, video.id)).toBeNull()
|
||||
session.videoValidation = { ...validation, passed: false }
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '设为主视频')).toBe(false)
|
||||
expect(fetcher.mock.calls.every(([, options]) => options?.method === 'GET')).toBe(true)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('模型输入变体可以预览,但不能设为业务主首帧', async () => {
|
||||
const variant = keyframeFixture({ source: 'provider_variant', isPrimary: false })
|
||||
expect(primaryKeyframe([{ ...variant, isPrimary: true }])).toBeUndefined()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>().mockImplementation(
|
||||
async url =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).endsWith('/keyframes') ? [variant] : []
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
const wrapper = mount(ShotProductionAssets, {
|
||||
props: { projectId: 'public-variant-contract', shot, disabled: false }
|
||||
})
|
||||
try {
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('模型输入变体')
|
||||
expect(wrapper.findAll('button').some(button => button.text() === '设为主首帧')).toBe(false)
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('提示词返回使用 shotId,保留正文、状态和更新时间', async () => {
|
||||
const result = {
|
||||
shotId: 'shot/db',
|
||||
videoPrompt: '镜头正文',
|
||||
negativePrompt: null,
|
||||
status: 'prompt_ready',
|
||||
updatedAt: '2026-09-10T00:00:00Z'
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ data: result })))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
expect(await storyboardApi.generatePrompt('shot/db', false)).toEqual(result)
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/storyboard-shots/shot%2Fdb/video-prompt')
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0]?.[1]?.body))).toEqual({ force: false })
|
||||
})
|
||||
|
||||
it('视频专项检查区分通过、失败和未执行,并保留后端摘要', async () => {
|
||||
const wrapper = mount(QualityResult, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
receipt: {
|
||||
kind: 'video',
|
||||
promoted: false,
|
||||
result: {
|
||||
...validation,
|
||||
passed: false,
|
||||
motionRealism: { enabled: true, passed: false, summary: '运动发生跳变' },
|
||||
spatialInteraction: { enabled: true, passed: true, summary: '空间关系一致' },
|
||||
physicalStructure: { enabled: false, reason: 'missing_prop_subject' }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
try {
|
||||
await expandSections()
|
||||
expect(wrapper.text()).toContain('运动真实性:未通过')
|
||||
expect(wrapper.text()).toContain('运动发生跳变')
|
||||
expect(wrapper.text()).toContain('空间交互:通过')
|
||||
expect(wrapper.text()).toContain('物理结构:未执行')
|
||||
} finally {
|
||||
wrapper.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
it('项目不再提供任务数组时,从每条工作流最新 checkpoint 判断运行状态', () => {
|
||||
expect(hasRunningWorkflow([])).toBe(false)
|
||||
expect(hasRunningWorkflow([record('breakdown', 1, 'running'), record('breakdown', 2, 'completed')])).toBe(false)
|
||||
expect(hasRunningWorkflow([record('breakdown', 1, 'running'), record('other', 2, 'completed')])).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
qualityKey,
|
||||
qualitySession,
|
||||
qualityTargets,
|
||||
savedVideoValidation,
|
||||
videoRepairInfo,
|
||||
sessionVideoValidation,
|
||||
isVisualValidation,
|
||||
validQualityInput
|
||||
} from '@/features/production/quality'
|
||||
import type { KeyframeReadiness } from '@/features/production/types'
|
||||
@@ -125,8 +125,7 @@ function server() {
|
||||
id: 'video-repair-1',
|
||||
status: 'queued',
|
||||
isPrimary: false,
|
||||
videoUrl: null,
|
||||
rawJson: JSON.stringify({ repair: { sourceVideoId: 'video-1', attempt: 1 } })
|
||||
videoUrl: null
|
||||
})
|
||||
}
|
||||
else if (path.endsWith('/repair')) data = repair
|
||||
@@ -136,13 +135,14 @@ function server() {
|
||||
shotId: 'shot-1',
|
||||
videoId: 'video-1',
|
||||
validatedAt: '2026-09-03',
|
||||
isPrimary: validation.passed && !!videoRepairInfo(video.rawJson),
|
||||
isPrimary: validation.passed,
|
||||
videoUrl: video.videoUrl,
|
||||
sampleFrames: [{ label: '中间', timeSeconds: 2 }],
|
||||
allowedTexts: []
|
||||
}
|
||||
else data = keyframeResult
|
||||
} else if (path.endsWith(`/projects/${projectId}`)) data = project
|
||||
else if (path.endsWith('/checkpoints')) data = []
|
||||
else if (path.includes('/readiness')) data = readiness
|
||||
else if (path.endsWith('/keyframes')) data = [keyframe]
|
||||
else if (path.endsWith('/videos')) data = [video]
|
||||
@@ -437,9 +437,10 @@ describe('质量面板渐进展示', () => {
|
||||
|
||||
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(sessionVideoValidation(data.projectId, 'shot-1', 'video-1')).toBeNull()
|
||||
expect(isVisualValidation(data.validation)).toBe(true)
|
||||
expect(isVisualValidation('{broken')).toBe(false)
|
||||
expect(isVisualValidation({ passed: true })).toBe(false)
|
||||
expect(allowedTextLines(' 招牌\n\n招牌\n编号 ')).toEqual(['招牌', '编号'])
|
||||
expect(validQualityInput(input({ maxRepairAttempts: 0 }))).toBe(true)
|
||||
expect(qualityKey('a', { kind: 'batch', episodeNo: 1, title: '' })).not.toBe(
|
||||
@@ -454,7 +455,9 @@ describe('视频修复候选与复检晋升', () => {
|
||||
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 } })
|
||||
data.validation.passed = false
|
||||
await data.service.run('validate', input())
|
||||
data.fetcher.mockClear()
|
||||
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')
|
||||
@@ -465,21 +468,23 @@ describe('视频修复候选与复检晋升', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('链路次数上限不能作为零次修复,已达上限不创建任务', async () => {
|
||||
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 }
|
||||
})
|
||||
data.validation.passed = false
|
||||
await data.service.run('validate', input())
|
||||
data.fetcher.mockClear()
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 0 }))
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||
expect(data.posts()).toHaveLength(0)
|
||||
vi.spyOn(qualityApi, 'repairVideo').mockRejectedValueOnce(new Error('修复次数上限已达到'))
|
||||
await data.service.run('repair', input({ maxRepairAttempts: 2 }))
|
||||
expect(qualityApi.repairVideo).toHaveBeenCalledTimes(1)
|
||||
expect(data.service.session.value.error).toContain('次数上限')
|
||||
expect(sessionVideoValidation(data.projectId, 'shot-1', 'video-1')?.passed).toBe(false)
|
||||
})
|
||||
|
||||
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!
|
||||
@@ -499,8 +504,7 @@ describe('视频修复候选与复检晋升', () => {
|
||||
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 } })
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
@@ -33,9 +33,7 @@ function project(id: string, status: ProjectStatus): ProjectDetail {
|
||||
updatedAt: '',
|
||||
episodes: [{ episode: 1, title: '部分剧集', content: '已经写入的内容' }],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
world: null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
castingStatusLabel,
|
||||
currentAnchor,
|
||||
groupIdentitySubjects,
|
||||
mergeCastingSubjects,
|
||||
readImageProvenance
|
||||
mergeCastingSubjects
|
||||
} from '@/features/subject-identity/model'
|
||||
import { identityImageFixture } from '@/features/subject-identity/testing/fixtures'
|
||||
import { formFixture } from '@/features/subject-images/testing/fixtures'
|
||||
@@ -83,21 +82,19 @@ describe('身份图与母版契约', () => {
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('实际提示词默认展开,可手动收起,轮询更新不强行重新展开', async () => {
|
||||
const image = identityImageFixture({ prompt: '本次实际生成提示词' })
|
||||
it('身份图库使用精简资产,不展示已移除的提示词和启停状态', () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [image] }
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: true,
|
||||
disabled: false,
|
||||
images: [identityImageFixture()]
|
||||
}
|
||||
})
|
||||
const panel = wrapper.get('.n-collapse-item')
|
||||
expect(panel.classes()).toContain('n-collapse-item--active')
|
||||
expect(panel.text()).toContain('本次实际生成提示词')
|
||||
await panel.get('.n-collapse-item__header-main').trigger('click')
|
||||
expect(panel.classes()).not.toContain('n-collapse-item--active')
|
||||
await wrapper.setProps({ images: [{ ...image, prompt: '更新后的实际提示词' }] })
|
||||
expect(panel.classes()).not.toContain('n-collapse-item--active')
|
||||
await panel.get('.n-collapse-item__header-main').trigger('click')
|
||||
expect(panel.classes()).toContain('n-collapse-item--active')
|
||||
expect(panel.text()).toContain('更新后的实际提示词')
|
||||
expect(wrapper.text()).toContain('当前身份母版')
|
||||
expect(wrapper.text()).not.toContain('本次实际提示词')
|
||||
expect(wrapper.text()).not.toContain('停用')
|
||||
})
|
||||
|
||||
it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => {
|
||||
@@ -105,26 +102,24 @@ describe('身份图与母版契约', () => {
|
||||
identityImageFixture({
|
||||
id: 'candidate',
|
||||
imageUrl: '/storage/candidate.png',
|
||||
isAnchor: false,
|
||||
enabled: false
|
||||
isAnchor: false
|
||||
}),
|
||||
identityImageFixture({
|
||||
id: 'failed',
|
||||
status: 'failed',
|
||||
imageUrl: null,
|
||||
isAnchor: false,
|
||||
enabled: false,
|
||||
|
||||
error: '图片模型超时'
|
||||
}),
|
||||
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png', width: 4096, height: 4096 }),
|
||||
identityImageFixture({ id: 'front', imageUrl: '/storage/front.png', viewType: 'front', isAnchor: false }),
|
||||
identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false, enabled: false }),
|
||||
identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false }),
|
||||
identityImageFixture({
|
||||
id: 'generating',
|
||||
status: 'generating',
|
||||
imageUrl: null,
|
||||
isAnchor: false,
|
||||
enabled: false
|
||||
isAnchor: false
|
||||
})
|
||||
]
|
||||
wrapper = mount(IdentityGallery, {
|
||||
@@ -196,9 +191,7 @@ describe('身份图与母版契约', () => {
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: ['candidate-a', 'candidate-b'].map(id =>
|
||||
identityImageFixture({ id, isAnchor: false, enabled: false })
|
||||
)
|
||||
images: ['candidate-a', 'candidate-b'].map(id => identityImageFixture({ id, isAnchor: false }))
|
||||
}
|
||||
})
|
||||
button('确认选角并锁定').click()
|
||||
@@ -225,7 +218,7 @@ describe('身份图与母版契约', () => {
|
||||
|
||||
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
|
||||
const candidate = identityImageFixture({ id: 'candidate', isAnchor: false })
|
||||
expect(canBeAnchor(front)).toBe(false)
|
||||
expect(canBeAnchor(candidate)).toBe(true)
|
||||
expect(currentAnchor([front, candidate])).toBeUndefined()
|
||||
@@ -233,14 +226,8 @@ describe('身份图与母版契约', () => {
|
||||
expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false)
|
||||
})
|
||||
|
||||
it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => {
|
||||
it('正式主体关联校验不接受不同主体的 form', () => {
|
||||
expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配')
|
||||
expect(readImageProvenance('{bad')).toEqual({})
|
||||
expect(readImageProvenance(null)).toEqual({})
|
||||
expect(readImageProvenance('{"identityAnchorImageId":"anchor","referenceImageId":7}')).toMatchObject({
|
||||
identityAnchorImageId: 'anchor',
|
||||
referenceImageId: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('选角就绪结果可以补入尚无形态的角色目录', () => {
|
||||
@@ -342,7 +329,7 @@ describe('身份图与母版契约', () => {
|
||||
expect(select.props('value')).toBe('front')
|
||||
})
|
||||
|
||||
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
|
||||
it('辅助图不能切换母版,精简资产不展示内部提示词', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
@@ -355,7 +342,7 @@ describe('身份图与母版契约', () => {
|
||||
})
|
||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||
await expandSections()
|
||||
expect(wrapper.text()).toContain('<script>不执行</script>')
|
||||
expect(wrapper.text()).not.toContain('本次实际提示词')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
@@ -388,6 +375,7 @@ describe('身份图与母版契约', () => {
|
||||
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成候选').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([[{}]])
|
||||
})
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ describe('主体目录母版缩略图', () => {
|
||||
identityImageFixture({
|
||||
id: 'candidate',
|
||||
isAnchor: false,
|
||||
enabled: false,
|
||||
|
||||
imageUrl: '/storage/candidate.png'
|
||||
}),
|
||||
identityImageFixture({
|
||||
|
||||
@@ -414,23 +414,14 @@ describe('过期首帧到具体素材定位', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
})
|
||||
it('身份主图真的过期时显示独立顶部警告,并可从定位模式筛选所有过期素材', async () => {
|
||||
it('精简图库没有历史母版字段时,不误报形态过期或破坏定位', async () => {
|
||||
const { first, router } = await gallery('?subjectFormId=form-other')
|
||||
first.subject.identity = { id: 'identity-1', isLocked: true, images: [{ id: 'anchor-new' }] }
|
||||
const refresh = wrapper!.get('[aria-label="刷新图库"]')
|
||||
await refresh.trigger('click')
|
||||
first.subject.identity = { id: 'identity-1', isLocked: true, anchorImageId: 'anchor-new' }
|
||||
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('形态主图需要更新')
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '筛选身份过期形态')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query.subjectFormId).toBeUndefined()
|
||||
expect(wrapper!.findAll('.form-image-card').map(item => item.attributes('data-form-id'))).toEqual([
|
||||
'form-db-1',
|
||||
'form-special'
|
||||
])
|
||||
expect(wrapper!.text()).not.toContain('形态主图需要更新')
|
||||
expect(router.currentRoute.value.query.subjectFormId).toBe('form-other')
|
||||
expect(wrapper!.findAll('button').some(item => item.text() === '重新生成过期形态')).toBe(false)
|
||||
})
|
||||
it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => {
|
||||
const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
|
||||
|
||||
@@ -167,7 +167,7 @@ describe('形态图片选择与操作', () => {
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
await expandSections()
|
||||
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
||||
expect(document.body.textContent).not.toContain('本次实际提示词')
|
||||
expect(document.querySelector('[role="dialog"] script')).toBeNull()
|
||||
button('设为主参考图').click()
|
||||
await flushPromises()
|
||||
|
||||
@@ -5,7 +5,7 @@ import { projectContextKey } from '@/features/projects/context'
|
||||
import { testProjectContext } from '@/testing/project-context'
|
||||
import { getOperation } from '@/features/workflows/operations'
|
||||
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
|
||||
import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from '@/features/subject-images/model'
|
||||
import { currentIdentityAnchorId, getImageSession } from '@/features/subject-images/model'
|
||||
import { useSubjectImages } from '@/features/subject-images/useSubjectImages'
|
||||
import FormPromptDialog from '@/features/subject-images/components/FormPromptDialog.vue'
|
||||
import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
|
||||
@@ -142,17 +142,15 @@ describe('形态正式提示词与批量配置', () => {
|
||||
await service.generateProject()
|
||||
expect(posts()).toHaveLength(0)
|
||||
})
|
||||
it.each(['character', 'scene', 'prop'])('%s 已锁定母版才参与过期判断,刷新只新增候选', async module => {
|
||||
it.each(['character', 'scene', 'prop'])('%s 使用明确母版 ID,不以缺少追溯信息触发生图', async module => {
|
||||
const { service, form, posts } = await setup()
|
||||
form.subject.module = module
|
||||
form.subject.identity = { id: 'identity', isLocked: false, images: [{ id: 'anchor-new' }] }
|
||||
form.subject.identity = { id: 'identity', isLocked: false, anchorImageId: 'anchor-new' }
|
||||
expect(currentIdentityAnchorId(form)).toBeUndefined()
|
||||
expect(isPrimaryIdentityStale(form)).toBe(false)
|
||||
form.subject.identity.isLocked = true
|
||||
expect(isPrimaryIdentityStale(form)).toBe(true)
|
||||
expect(currentIdentityAnchorId(form)).toBe('anchor-new')
|
||||
await service.query.refresh()
|
||||
await service.generateStale()
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ setPrimary: false })
|
||||
expect(posts()).toHaveLength(0)
|
||||
})
|
||||
it('正式提示词确认必须勾选,未确认不发送事件', async () => {
|
||||
const form = formFixture()
|
||||
|
||||
@@ -65,9 +65,7 @@ const fixture: ProjectDetail = {
|
||||
updatedAt: '2026-08-27T00:00:00Z',
|
||||
episodes: [{ episode: 1, title: '来信', content: '<script>不要执行模型内容</script>\n第一场:旧书店。' }],
|
||||
characters: [{ id: 'character', name: '林知夏' }],
|
||||
world: { era: '当代' },
|
||||
reviews: [],
|
||||
tasks: []
|
||||
world: { era: '当代' }
|
||||
}
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
@@ -219,7 +217,14 @@ describe('内容级滚动下的生产反馈', () => {
|
||||
getProductionSession(fixture.id).receipt = {
|
||||
kind: 'retry',
|
||||
title: '重试失败视频任务',
|
||||
result: { totalFailed: 1, retried: 0, failed: 1, failures: [{ shotId: 'shot-1', error: '供应商不可用' }] }
|
||||
result: {
|
||||
totalFailed: 1,
|
||||
retryable: 1,
|
||||
skipped: 0,
|
||||
retried: 0,
|
||||
failed: 1,
|
||||
failures: [{ shotId: 'shot-1', error: '供应商不可用' }]
|
||||
}
|
||||
}
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('true')
|
||||
@@ -392,7 +397,10 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await mountAssets('style')
|
||||
await wrapper!.get('[aria-label="风格图片地址"]').setValue('javascript:alert(1)')
|
||||
expect(button('登记参考图').disabled).toBe(true)
|
||||
button('登记参考图').click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.n-form-item-feedback--error')?.textContent).toContain('请填写有效的')
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(false)
|
||||
await wrapper!.get('[aria-label="风格图片地址"]').setValue('/storage/style.png')
|
||||
button('登记参考图').click()
|
||||
await flushPromises()
|
||||
@@ -437,7 +445,10 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
expect(wrapper!.text()).toContain('默认采用中国人物选角基线')
|
||||
expect(wrapper!.text()).toContain('人物风格与选角背景')
|
||||
await wrapper!.get('#style-constraints').setValue('{"不应静默丢弃":true}')
|
||||
expect(button('保存视觉风格').disabled).toBe(true)
|
||||
button('保存视觉风格').click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.n-form-item-feedback--error')?.textContent).toContain('JSON 字符串数组')
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(false)
|
||||
await wrapper!.get('#style-constraints').setValue('["真人写实"]')
|
||||
await wrapper!.get('#style-lock').trigger('click')
|
||||
await wrapper!.get('#style-name').setValue('雨夜风格')
|
||||
@@ -611,7 +622,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
return jsonResponse(identityImageFixture({ id: 'candidate' }))
|
||||
}
|
||||
if (String(url).endsWith('/identity/images'))
|
||||
return jsonResponse([identityImageFixture({ id: 'candidate', enabled: selected, isAnchor: selected })])
|
||||
return jsonResponse([identityImageFixture({ id: 'candidate', isAnchor: selected })])
|
||||
return jsonResponse(identityFixture())
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
@@ -697,7 +708,7 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
it('Character 确认选角原子切换 Anchor 并锁定 Identity,不调用普通母版接口', async () => {
|
||||
let locked = false
|
||||
let selected = false
|
||||
const candidate = identityImageFixture({ id: 'casting-1', enabled: false, isAnchor: false })
|
||||
const candidate = identityImageFixture({ id: 'casting-1', isAnchor: false })
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
if (path.endsWith('/subject-forms')) return jsonResponse([formFixture()])
|
||||
@@ -725,14 +736,15 @@ describe('视觉风格与主体身份工作区', () => {
|
||||
}
|
||||
]
|
||||
})
|
||||
if (path.endsWith('/identity/images'))
|
||||
return jsonResponse([{ ...candidate, enabled: selected, isAnchor: selected }])
|
||||
if (path.endsWith('/identity/images')) return jsonResponse([{ ...candidate, isAnchor: selected }])
|
||||
if (init?.method === 'PUT' && path.endsWith('/casting')) {
|
||||
locked = true
|
||||
selected = true
|
||||
return jsonResponse({
|
||||
identity: { ...identityFixture({ isLocked: true }), images: undefined },
|
||||
anchor: { ...candidate, enabled: true }
|
||||
identityId: candidate.identityId,
|
||||
subjectId: 'subject-db-1',
|
||||
isLocked: true,
|
||||
anchor: { ...candidate, isAnchor: true }
|
||||
})
|
||||
}
|
||||
return jsonResponse(identityFixture({ isLocked: locked }))
|
||||
@@ -834,9 +846,11 @@ describe('工作台页面交互', () => {
|
||||
selectControl(wrapper!, 'aria-label', '筛选主体类型').vm.$emit('update:value', 'all')
|
||||
await filters.get('input[aria-label="搜索形态图片"]').setValue('旧书店')
|
||||
expect(filters.get('.filter-result-count').text()).toBe('1 个形态')
|
||||
selectControl(wrapper!, 'aria-label', '筛选图片状态').vm.$emit('update:value', 'stale')
|
||||
await flushPromises()
|
||||
expect(filters.get('.filter-result-count').text()).toBe('0 个形态')
|
||||
expect(selectControl(wrapper!, 'aria-label', '筛选图片状态').props('options')).not.toContainEqual({
|
||||
label: '身份过期',
|
||||
value: 'stale'
|
||||
})
|
||||
expect(filters.get('.filter-result-count').text()).toBe('1 个形态')
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
@@ -924,7 +938,9 @@ describe('工作台页面交互', () => {
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
document
|
||||
.querySelector('.app-dialog form')!
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === 'POST')!
|
||||
expect(post[0]).toBe('/api/subject-forms/form-db-1/images')
|
||||
@@ -1382,7 +1398,8 @@ describe('工作台页面交互', () => {
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
id: 'shot-db-1-1',
|
||||
shotId: 'shot-db-1-1',
|
||||
updatedAt: '2026-09-10T00:00:00Z',
|
||||
videoPrompt: '<script>模型内容</script>',
|
||||
negativePrompt: '模糊',
|
||||
status: 'prompt_ready'
|
||||
|
||||
Reference in New Issue
Block a user