feat: 收口组件样式并调整移动端侧栏
This commit is contained in:
@@ -1,436 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import BreakdownPage from './BreakdownPage.vue'
|
||||
import { projectContextKey, type useProjectData } from '../projects/context'
|
||||
import type { ProjectDetail } from '../projects/types'
|
||||
import type { Checkpoint } from '../workflows/types'
|
||||
import type { BreakdownModule, EpisodePlan } from './types'
|
||||
import { drawerPanel, selectMenu } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 长主体、长分镜与长任务列表,用真实 Naive 滚动组件验证内容边界。 */
|
||||
function checkpoint(): Checkpoint {
|
||||
const plan: EpisodePlan = {
|
||||
episodeNo: 1,
|
||||
episodeTitle: '长剧集',
|
||||
storyGoal: '',
|
||||
centralConflict: '',
|
||||
emotionalArc: '',
|
||||
pacing: '',
|
||||
endingHook: '',
|
||||
beats: Array.from({ length: 25 }, (_, i) => ({
|
||||
beatNo: i + 1,
|
||||
title: `节拍 ${i + 1}`,
|
||||
purpose: 'action',
|
||||
description: '节拍内容'.repeat(100),
|
||||
visualFocus: '',
|
||||
narrativeGoal: '',
|
||||
emotionalTone: '',
|
||||
estimatedDurationSeconds: 5,
|
||||
subjectRefs: [],
|
||||
isKeyBeat: false
|
||||
}))
|
||||
}
|
||||
return {
|
||||
checkpointId: 'long-breakdown',
|
||||
workflowName: 'breakdown',
|
||||
createdAt: '2026-08-28T00:00:00Z',
|
||||
state: {
|
||||
workflowExecution: { executionId: 'run', status: 'completed', startedAt: '2026-08-28T00:00:00Z' },
|
||||
breakdownResult: {
|
||||
subjectCandidates: (['character', 'scene', 'prop'] as BreakdownModule[]).flatMap(module =>
|
||||
Array.from({ length: 30 }, (_, i) => ({
|
||||
profileId: `${module}-${i}`,
|
||||
name: `${module} 主体 ${i + 1}`,
|
||||
ref: `@${module}${i}`,
|
||||
description: '很长的主体描述。'.repeat(100),
|
||||
module,
|
||||
appearance_prompt: '外观描述'
|
||||
}))
|
||||
),
|
||||
subjectForms: [],
|
||||
storyboardPlans: [plan],
|
||||
storyboardEpisodeShots: [
|
||||
{
|
||||
episodeNo: 1,
|
||||
episodePlan: plan,
|
||||
beatShots: plan.beats.map(beat => ({
|
||||
beatNo: beat.beatNo,
|
||||
shots: [
|
||||
{
|
||||
shotNo: 1,
|
||||
title: `镜头 ${beat.beatNo}`,
|
||||
description: '镜头内容',
|
||||
visualFocus: '',
|
||||
subjectRefs: [],
|
||||
durationSeconds: 5
|
||||
}
|
||||
]
|
||||
}))
|
||||
}
|
||||
]
|
||||
},
|
||||
tasks: Array.from({ length: 50 }, (_, i) => ({
|
||||
taskId: `task-${i}`,
|
||||
module: 'character',
|
||||
group: {
|
||||
groupId: `group-${i}`,
|
||||
groupNo: i + 1,
|
||||
startEpisodeNo: i + 1,
|
||||
endEpisodeNo: i + 1,
|
||||
episodes: []
|
||||
},
|
||||
status: 'failed',
|
||||
attempt: 1,
|
||||
errorMessage: `任务错误 ${i + 1}`
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 提供响应式 checkpoint,模拟轮询更新但不连接后端或启动模型任务。 */
|
||||
function mountPage(records = [checkpoint()], episodes = 1): ReturnType<typeof useProjectData> {
|
||||
const project: ProjectDetail = {
|
||||
id: 'breakdown-scroll-test',
|
||||
title: '滚动回归',
|
||||
topic: '',
|
||||
style: '',
|
||||
status: 'completed',
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: [],
|
||||
episodes: episodes ? [{ episode: 1, title: '第一集', content: '正文' }] : []
|
||||
}
|
||||
const data = ref({ project, checkpoints: records })
|
||||
const provided: ReturnType<typeof useProjectData> = {
|
||||
data,
|
||||
project: computed(() => data.value.project),
|
||||
checkpoints: computed(() => data.value.checkpoints),
|
||||
loading: ref(false),
|
||||
error: ref(''),
|
||||
updatedAt: ref(''),
|
||||
refresh: vi.fn<() => Promise<void>>().mockResolvedValue()
|
||||
}
|
||||
wrapper = mount(BreakdownPage, {
|
||||
attachTo: document.body,
|
||||
global: { provide: { [projectContextKey as symbol]: provided }, stubs: { RouterLink: true } }
|
||||
})
|
||||
return provided
|
||||
}
|
||||
|
||||
/** 切换真实标签,不直接修改组件内部状态。 */
|
||||
async function selectTab(label: string) {
|
||||
await wrapper!
|
||||
.findAll('.result-toolbar .n-tabs-tab')
|
||||
.find(tab => tab.text().startsWith(label))!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('拆解页内容滚动', () => {
|
||||
it('拆解设置保留输入标签、单行单位与模块说明,改版后仍校验模块和剧本状态', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const provided = mountPage()
|
||||
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<HTMLInputElement>('#group-size').element.value).toBe('3')
|
||||
expect(config.get('.breakdown-group-unit').text()).toBe('集 / 组')
|
||||
expect(config.get('fieldset > legend').text()).toBe('抽取模块')
|
||||
const options = config.findAll('.breakdown-module-option')
|
||||
expect(options).toHaveLength(3)
|
||||
const preview = config.get<HTMLButtonElement>('.breakdown-preview-button')
|
||||
expect(preview.element.disabled).toBe(false)
|
||||
for (const option of options) {
|
||||
const checkbox = option.get('[role="checkbox"]')
|
||||
const description = option.get('.breakdown-module-description')
|
||||
expect(checkbox.attributes('aria-describedby')).toBe(description.attributes('id'))
|
||||
expect(checkbox.find('.breakdown-module-description').exists()).toBe(false)
|
||||
expect(description.text()).not.toBe('')
|
||||
await checkbox.trigger('click')
|
||||
}
|
||||
expect(preview.element.disabled).toBe(true)
|
||||
expect(drawerPanel().text()).toContain('至少选择一个抽取模块')
|
||||
await options[1]!.get('[role="checkbox"]').trigger('click')
|
||||
expect(preview.element.disabled).toBe(false)
|
||||
provided.data.value!.project.status = 'generating'
|
||||
await flushPromises()
|
||||
expect(preview.element.disabled).toBe(true)
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('拆解配置按抽屉宽度换行,单位不收缩,三类控件对齐且说明位于控件下方', () => {
|
||||
// DOM 环境不计算坐标,检查统一标题偏移、34px 控件行和容器断点契约。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
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:\s*0 0 8px;[^}]*line-height:\s*20px;/)
|
||||
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;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.breakdown-preview-button\.n-button\s*\{[^}]*align-self:\s*start;[^}]*margin-top:\s*var\(--breakdown-label-offset\);/
|
||||
)
|
||||
expect(css).toContain('@container breakdown-settings (max-width: 780px)')
|
||||
expect(css).toContain('@container breakdown-settings (max-width: 560px)')
|
||||
expect(css).toContain('repeat(auto-fit, minmax(min(100%, 160px), 1fr))')
|
||||
expect(readFileSync('src/styles.css', 'utf8')).not.toContain('.breakdown-config')
|
||||
})
|
||||
|
||||
it.each(['人物', '场景', '道具'])('%s 使用共用固定斑马纹样式,搜索后保持列表条目结构', async label => {
|
||||
mountPage()
|
||||
await selectTab(label)
|
||||
expect(wrapper!.find('.history-panel').exists()).toBe(false)
|
||||
await selectMenu('拆解更多操作', '执行记录')
|
||||
const list = wrapper!.get('.subject-record-list')
|
||||
expect(list.classes()).toContain('record-list')
|
||||
expect(list.findAll(':scope > article')).toHaveLength(30)
|
||||
const toolbar = wrapper!.get('.subject-list-toolbar')
|
||||
expect(toolbar.element.firstElementChild).toBe(toolbar.get('.subject-list-search').element)
|
||||
expect(toolbar.element.lastElementChild).toBe(toolbar.get('.subject-list-count').element)
|
||||
expect(toolbar.get('.n-input__prefix svg').attributes('aria-hidden')).toBe('true')
|
||||
expect(toolbar.find(':scope > svg').exists()).toBe(false)
|
||||
expect(toolbar.get('[role="status"]').text()).toBe('共 30 个主体')
|
||||
const input = toolbar.get('input[aria-label="搜索主体"]')
|
||||
await input.setValue('主体 30')
|
||||
expect(list.findAll(':scope > article')).toHaveLength(1)
|
||||
expect(list.get(':scope > article h3').text()).toContain('主体 30')
|
||||
expect(toolbar.get('[role="status"]').text()).toBe('匹配 1 / 30 个主体')
|
||||
await input.setValue('不存在的主体')
|
||||
expect(toolbar.get('[role="status"]').text()).toBe('匹配 0 / 30 个主体')
|
||||
expect(wrapper!.text()).toContain('没有匹配的主体')
|
||||
await input.setValue('')
|
||||
expect(toolbar.get('[role="status"]').text()).toBe('共 30 个主体')
|
||||
expect(wrapper!.findAll('.subject-record-list > article')).toHaveLength(30)
|
||||
})
|
||||
|
||||
it('主体搜索与统计靠左居中,窄内容区可换行,输入框不再使用外置图标容器', () => {
|
||||
// DOM 环境不计算布局,保护输入框宽度、主题统计色与窄屏换行的样式契约。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.subject-list-toolbar\s*\{[^}]*display:\s*flex;[^}]*flex-wrap:\s*wrap;[^}]*align-items:\s*center;[^}]*justify-content:\s*flex-start;/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.subject-list-search\.n-input\s*\{[^}]*flex:\s*0 1 260px;[^}]*min-width:\s*0;[^}]*max-width:\s*100%;/
|
||||
)
|
||||
expect(css).toMatch(/\.subject-list-count\s*\{[^}]*color:\s*var\(--app-muted\);/)
|
||||
expect(css + readFileSync('src/styles.css', 'utf8')).not.toContain('.search-field')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['人物', 'character'],
|
||||
['场景', 'scene'],
|
||||
['道具', 'prop']
|
||||
] as const)('%s 的奇偶主体展开后均使用独立形态块,搜索重排不影响形态归属', async (label, module) => {
|
||||
const record = checkpoint()
|
||||
// 前两个主体各准备两种形态,覆盖奇偶条纹和默认标签,不调用模型接口。
|
||||
record.state.breakdownResult!.subjectForms = [0, 1].flatMap(subjectIndex =>
|
||||
[0, 1].map(formIndex => ({
|
||||
formId: `${module}-${subjectIndex}-form-${formIndex}`,
|
||||
profileId: `${module}-${subjectIndex}`,
|
||||
type: module,
|
||||
name: `主体 ${subjectIndex + 1} 形态 ${formIndex + 1}`,
|
||||
isDefault: formIndex === 0,
|
||||
description: '形态描述',
|
||||
appearancePrompt: '形态提示词'
|
||||
}))
|
||||
)
|
||||
mountPage([record])
|
||||
await selectTab(label)
|
||||
const articles = wrapper!.findAll('.subject-record-list > article')
|
||||
for (const index of [0, 1]) {
|
||||
const article = articles[index]!
|
||||
await article.get('.n-collapse-item__header-main').trigger('click')
|
||||
await flushPromises()
|
||||
const cards = article.findAll('.subject-form-card')
|
||||
expect(cards).toHaveLength(2)
|
||||
expect(cards[0]!.get('.n-tag').text()).toBe('默认')
|
||||
for (const card of cards) {
|
||||
expect(card.classes()).not.toContain('surface-inset')
|
||||
expect(card.text()).toContain(`主体 ${index + 1} 形态`)
|
||||
expect(card.text()).toContain('形态描述')
|
||||
expect(card.text()).toContain('形态提示词')
|
||||
}
|
||||
}
|
||||
// 原偶数主体过滤后成为首项,仍由当前 DOM 的奇偶选择器决定内外层底色。
|
||||
await wrapper!.get('input[aria-label="搜索主体"]').setValue(`@${module}1`)
|
||||
const first = wrapper!.get('.subject-record-list > article')
|
||||
expect(first.findAll('.subject-form-card')).toHaveLength(2)
|
||||
expect(first.get('.subject-form-card').text()).toContain('主体 2 形态 1')
|
||||
})
|
||||
|
||||
it('形态块在两种主体条纹上采用相反灰阶,以间距分组且没有整块悬停态', () => {
|
||||
// DOM 环境不提供真实主题绘制,检查奇偶行底色、内边距和状态选择器契约。
|
||||
const css = readFileSync('src/styles.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.subject-form-card\s*\{[^}]*margin-top:\s*12px;[^}]*padding:\s*16px;[^}]*background:\s*var\(--app-subtle\);/
|
||||
)
|
||||
expect(css).toMatch(
|
||||
/\.subject-record-list > article:nth-child\(even\) \.subject-form-card\s*\{\s*background:\s*var\(--app-control\);/
|
||||
)
|
||||
expect(css).not.toMatch(/\.subject-form-card[^{}]*(?::hover|:focus-within)/)
|
||||
})
|
||||
|
||||
it('主体奇数行常驻原悬停底色,偶数行不变,整行不再随鼠标或焦点变色', () => {
|
||||
// DOM 环境无法模拟浏览器 :hover 命中;检查静态底色与无整行交互选择器的契约。
|
||||
const css = readFileSync('src/styles.css', 'utf8')
|
||||
expect(css).toMatch(/\.record-list > article:nth-child\(even\)\s*\{\s*background:\s*var\(--app-subtle\);/)
|
||||
expect(css).toMatch(
|
||||
/\.subject-record-list > article:nth-child\(odd\)\s*\{\s*background:\s*var\(--app-control\);/
|
||||
)
|
||||
expect(css + readFileSync('src/admin.css', 'utf8')).not.toMatch(
|
||||
/\.subject-record-list[^{}]*(?::hover|:focus-within)/
|
||||
)
|
||||
// 仅移除整行变色,不影响内部链接、折叠等控件的键盘焦点提示。
|
||||
expect(css).toMatch(/:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--color-accent\);/)
|
||||
})
|
||||
|
||||
it('分镜选择与计数、入口同排,默认显示实际剧集,切换后内容和统计同步', async () => {
|
||||
const record = checkpoint()
|
||||
const result = record.state.breakdownResult!
|
||||
const first = result.storyboardPlans![0]!
|
||||
const second = { ...first, episodeNo: 2, episodeTitle: '第二集', beats: first.beats.slice(0, 2) }
|
||||
result.storyboardPlans!.push(second)
|
||||
result.storyboardEpisodeShots!.push({
|
||||
episodeNo: 2,
|
||||
episodePlan: second,
|
||||
beatShots: result.storyboardEpisodeShots![0]!.beatShots.slice(0, 2)
|
||||
})
|
||||
const provided = mountPage([record])
|
||||
await selectTab('分镜')
|
||||
const toolbar = wrapper!.get('.breakdown-storyboard-toolbar')
|
||||
expect(toolbar.get('.breakdown-episode-picker > span').text()).toBe('选择剧集')
|
||||
const select = toolbar.getComponent(NSelect)
|
||||
expect(select.props('value')).toBe(1)
|
||||
expect(toolbar.text()).toContain('长剧集')
|
||||
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('25 个节拍 · 25 个镜头')
|
||||
expect(toolbar.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe(
|
||||
'/projects/breakdown-scroll-test/storyboard'
|
||||
)
|
||||
expect(wrapper!.find('.breakdown-storyboard > .surface-inset').exists()).toBe(false)
|
||||
select.vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
expect(select.props('value')).toBe(2)
|
||||
expect(toolbar.text()).toContain('第二集')
|
||||
expect(toolbar.get('.breakdown-storyboard-summary').text()).toContain('2 个节拍 · 2 个镜头')
|
||||
expect(wrapper!.findAll('.beat-section')).toHaveLength(2)
|
||||
// 轮询删除当前选项时,输入框与正文一起回到仍存在的第一集。
|
||||
provided.data.value!.checkpoints = [checkpoint()]
|
||||
await flushPromises()
|
||||
expect(select.props('value')).toBe(1)
|
||||
expect(wrapper!.findAll('.beat-section')).toHaveLength(25)
|
||||
})
|
||||
|
||||
it('分镜为空时保留空提示与进入设计入口,不显示空选择器', async () => {
|
||||
const record = checkpoint()
|
||||
record.state.breakdownResult!.storyboardPlans = []
|
||||
record.state.breakdownResult!.storyboardEpisodeShots = []
|
||||
mountPage([record])
|
||||
await selectTab('分镜')
|
||||
expect(wrapper!.get('.breakdown-storyboard').text()).toContain('分镜规划尚未生成')
|
||||
expect(wrapper!.find('.breakdown-episode-picker').exists()).toBe(false)
|
||||
expect(wrapper!.get('.breakdown-storyboard-summary router-link-stub').attributes('to')).toBe(
|
||||
'/projects/breakdown-scroll-test/storyboard'
|
||||
)
|
||||
})
|
||||
|
||||
it('分镜顶部无叠加操作行,选择标签保持单行且统计操作垂直居中', () => {
|
||||
// 仅保护样式契约,DOM 环境不提供真实布局坐标。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(/\.breakdown-storyboard\s*\{[^}]*padding:\s*16px 20px 20px;/)
|
||||
expect(css).toMatch(/\.breakdown-episode-picker > span\s*\{[^}]*flex-shrink:\s*0;[^}]*white-space:\s*nowrap;/)
|
||||
expect(css).toMatch(/\.breakdown-storyboard-summary\s*\{[^}]*align-items:\s*center;/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['人物', 'character 主体 30'],
|
||||
['场景', 'scene 主体 30'],
|
||||
['道具', 'prop 主体 30'],
|
||||
['分镜', '镜头 25'],
|
||||
['任务明细', '任务错误 50']
|
||||
])('%s 的末项始终放在独立结果滚动容器内', async (label, lastItem) => {
|
||||
mountPage()
|
||||
await selectTab(label)
|
||||
await selectMenu('拆解更多操作', '执行记录')
|
||||
expect(wrapper!.find('.workspace-scroll').exists()).toBe(false)
|
||||
const results = wrapper!.get('.breakdown-results-scroll .n-scrollbar-container')
|
||||
expect(results.text()).toContain(lastItem)
|
||||
expect(results.find('.result-toolbar').exists()).toBe(false)
|
||||
expect(results.find('.history-panel').exists()).toBe(false)
|
||||
expect(wrapper!.get('.history-panel .n-scrollbar-container').text()).toContain('执行记录')
|
||||
expect(wrapper!.get('[data-workspace-tools]').attributes('aria-expanded')).toBe('false')
|
||||
expect(results.find('.table-scroll').exists()).toBe(label === '任务明细')
|
||||
})
|
||||
|
||||
it('刷新和开关配置保留阅读位置,切换标签只重置结果区,不改变执行记录位置', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const provided = mountPage()
|
||||
await selectMenu('拆解更多操作', '执行记录')
|
||||
const results = wrapper!.get<HTMLElement>('.breakdown-results-scroll .n-scrollbar-container').element
|
||||
const history = wrapper!.get<HTMLElement>('.history-panel .n-scrollbar-container').element
|
||||
results.scrollTop = 900
|
||||
history.scrollTop = 120
|
||||
await wrapper!.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(drawerPanel().element.closest('.workspace-split')).toBeNull()
|
||||
await drawerPanel().get('#group-size').setValue('2')
|
||||
await drawerPanel().get('[aria-label="关闭操作面板"]').trigger('click')
|
||||
provided.data.value!.checkpoints = [checkpoint()]
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.breakdown-results-scroll .n-scrollbar-container').element).toBe(results)
|
||||
expect(results.scrollTop).toBe(900)
|
||||
await selectTab('场景')
|
||||
const next = wrapper!.get<HTMLElement>('.breakdown-results-scroll .n-scrollbar-container').element
|
||||
expect(next).not.toBe(results)
|
||||
expect(next.scrollTop).toBe(0)
|
||||
expect(wrapper!.get('.history-panel .n-scrollbar-container').element).toBe(history)
|
||||
expect(history.scrollTop).toBe(120)
|
||||
await wrapper!.get('[data-workspace-tools]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(drawerPanel().get<HTMLInputElement>('#group-size').element.value).toBe('2')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('关闭设置时错误仍可见,详细校验与恢复在面板内,不挤占结果高度', async () => {
|
||||
const record = checkpoint()
|
||||
record.state.workflowExecution!.status = 'failed'
|
||||
record.state.workflowExecution!.errorMessage = '工作流中断'
|
||||
record.state.breakdownResult!.storyboardShotValidation = {
|
||||
valid: false,
|
||||
issues: [{ episodeNo: 1, message: '缺少形态绑定' }]
|
||||
}
|
||||
mountPage([record])
|
||||
const feedback = wrapper!.get('.workspace-feedback')
|
||||
expect(feedback.text()).toContain('工作流中断')
|
||||
expect(feedback.text()).toContain('分镜校验未通过,共 1 项问题')
|
||||
expect(feedback.find('.breakdown-results-section').exists()).toBe(false)
|
||||
await feedback.get('button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(drawerPanel().text()).toContain('缺少形态绑定')
|
||||
expect(drawerPanel().text()).toContain('重试失败抽取')
|
||||
})
|
||||
|
||||
it('没有正式剧集时保留可滚动的空状态,不显示拆解设置与结果', () => {
|
||||
mountPage([], 0)
|
||||
expect(wrapper!.get('.panel-scroll .n-scrollbar-container').text()).toContain('还没有可拆解的剧集')
|
||||
expect(wrapper!.find('.breakdown-results-section').exists()).toBe(false)
|
||||
expect(wrapper!.find('[data-workspace-tools]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -497,3 +497,95 @@ function exportResult() {
|
||||
</template>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.group-preview {
|
||||
@apply mt-6 pt-5;
|
||||
}
|
||||
.group-chip {
|
||||
@apply flex items-center gap-2.5 bg-(--app-subtle) rounded-none py-2 px-[11px] text-[11px];
|
||||
}
|
||||
.recovery-strip {
|
||||
@apply bg-(--app-subtle) flex justify-between flex-wrap items-center gap-[15px] p-[17px] rounded-none;
|
||||
}
|
||||
.breakdown-settings-panel {
|
||||
container: breakdown-settings / inline-size;
|
||||
}
|
||||
.breakdown-config {
|
||||
@apply grid grid-cols-[180px_minmax(0,_1fr)_auto] items-start gap-y-4 gap-x-6;
|
||||
--breakdown-label-offset: 28px;
|
||||
}
|
||||
.breakdown-config .field-label {
|
||||
@apply mt-0 mx-0 mb-2 p-0 leading-[20px];
|
||||
}
|
||||
.breakdown-group-field,
|
||||
.breakdown-modules-field,
|
||||
.breakdown-module-option {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.breakdown-group-input {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
.breakdown-group-number.n-input-number {
|
||||
@apply flex-[0_0_120px] w-[120px];
|
||||
}
|
||||
.breakdown-group-unit {
|
||||
@apply shrink-0 whitespace-nowrap text-muted text-sm;
|
||||
}
|
||||
.breakdown-modules-field {
|
||||
@apply m-0 p-0 border-0;
|
||||
}
|
||||
.breakdown-module-options {
|
||||
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-y-3 gap-x-4;
|
||||
}
|
||||
.breakdown-module-description {
|
||||
@apply mt-1 mx-0 mb-0 pl-6 text-muted text-[11px] leading-[18px] wrap-anywhere;
|
||||
}
|
||||
.breakdown-preview-button.n-button {
|
||||
@apply self-start mt-[var(--breakdown-label-offset)] whitespace-nowrap;
|
||||
}
|
||||
@container breakdown-settings (max-width: 780px) {
|
||||
.breakdown-config {
|
||||
@apply grid-cols-[180px_minmax(0,_1fr)];
|
||||
}
|
||||
.breakdown-preview-button.n-button {
|
||||
@apply col-[2] justify-self-end mt-0;
|
||||
}
|
||||
}
|
||||
@container breakdown-settings (max-width: 560px) {
|
||||
.breakdown-config {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.breakdown-module-options {
|
||||
@apply grid-cols-[repeat(auto-fit,_minmax(min(100%,_160px),_1fr))];
|
||||
}
|
||||
.breakdown-preview-button.n-button {
|
||||
@apply col-[1];
|
||||
}
|
||||
}
|
||||
.result-toolbar .n-tabs-tab__label {
|
||||
@apply inline-flex gap-1.5;
|
||||
}
|
||||
.breakdown-results-section {
|
||||
@apply flex flex-col flex-1 min-h-0 min-w-0 gap-2;
|
||||
}
|
||||
.breakdown-results-section > .result-toolbar {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.breakdown-results-section > .content-with-history {
|
||||
@apply flex-1 h-auto min-h-0 grid-rows-[minmax(0,_1fr)];
|
||||
}
|
||||
.breakdown-results-scroll .n-scrollbar-content {
|
||||
@apply wrap-anywhere;
|
||||
}
|
||||
.breakdown-results-section > .content-with-history.history-hidden {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.breakdown-results-section > .content-with-history {
|
||||
@apply grid-rows-[minmax(0,_1fr)_min(25%,_130px)];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -106,3 +106,52 @@ const purposeLabels: Record<string, string> = {
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.beat-section {
|
||||
@apply mt-[25px] pt-[23px];
|
||||
}
|
||||
.beat-heading {
|
||||
@apply flex items-center gap-2.5;
|
||||
}
|
||||
.beat-number {
|
||||
@apply text-muted font-mono text-[11px];
|
||||
}
|
||||
.shot-row {
|
||||
@apply flex gap-[17px] p-[17px] rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.shot-label {
|
||||
@apply shrink-0 w-[47px] text-[10px] text-muted;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.shot-row {
|
||||
@apply gap-3 p-[13px];
|
||||
}
|
||||
}
|
||||
.breakdown-storyboard {
|
||||
@apply pt-4 px-5 pb-5;
|
||||
}
|
||||
.breakdown-storyboard-toolbar {
|
||||
@apply flex items-center justify-between flex-wrap gap-y-3 gap-x-6 mb-4;
|
||||
}
|
||||
.breakdown-episode-picker {
|
||||
@apply flex items-center gap-3 flex-[0_1_420px] min-w-0 text-sm;
|
||||
}
|
||||
.breakdown-episode-picker > span {
|
||||
@apply shrink-0 whitespace-nowrap;
|
||||
}
|
||||
.breakdown-episode-picker .n-select {
|
||||
@apply flex-1 min-w-0;
|
||||
}
|
||||
.breakdown-storyboard-summary {
|
||||
@apply flex items-center flex-wrap gap-y-2 gap-x-5 min-h-[34px] ml-auto;
|
||||
}
|
||||
.breakdown-storyboard-count {
|
||||
@apply text-muted text-xs whitespace-nowrap;
|
||||
}
|
||||
.breakdown-storyboard-summary .text-button {
|
||||
@apply min-h-[34px] whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -117,3 +117,35 @@ const filtered = computed(() =>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.subject-record-list > article:nth-child(odd) {
|
||||
@apply bg-(--app-control);
|
||||
}
|
||||
.subject-form-card {
|
||||
@apply mt-3 p-4 min-w-0 wrap-anywhere bg-(--app-subtle);
|
||||
}
|
||||
.subject-record-list > article:nth-child(even) .subject-form-card {
|
||||
@apply bg-(--app-control);
|
||||
}
|
||||
.subject-details summary {
|
||||
@apply inline-flex items-center gap-[5px] text-ink text-[11px] list-none;
|
||||
}
|
||||
.subject-details summary::-webkit-details-marker {
|
||||
@apply hidden;
|
||||
}
|
||||
.subject-details[open] summary svg {
|
||||
@apply rotate-180;
|
||||
}
|
||||
.subject-list-toolbar {
|
||||
@apply flex flex-wrap items-center justify-start gap-y-2.5 gap-x-3 mb-5;
|
||||
}
|
||||
.subject-list-search.n-input {
|
||||
@apply flex-[0_1_260px] min-w-0 max-w-full;
|
||||
}
|
||||
.subject-list-count {
|
||||
@apply shrink-0 text-muted text-xs whitespace-nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -249,3 +249,49 @@ function exportScript() {
|
||||
</div></div
|
||||
></WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.stage-strip {
|
||||
@apply flex flex-wrap items-center gap-3.5;
|
||||
}
|
||||
.stage-item {
|
||||
@apply flex items-center gap-[7px] text-muted text-[11px];
|
||||
}
|
||||
.stage-item.done {
|
||||
@apply text-ink;
|
||||
}
|
||||
.stage-arrow {
|
||||
@apply ml-3 text-muted;
|
||||
}
|
||||
.json-view {
|
||||
@apply whitespace-pre-wrap wrap-anywhere p-4 bg-(--app-subtle) rounded-none font-mono text-[11px] leading-[1.9];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.stage-strip {
|
||||
@apply gap-[9px];
|
||||
}
|
||||
.stage-arrow {
|
||||
@apply ml-[3px];
|
||||
}
|
||||
}
|
||||
.script-section {
|
||||
@apply flex-1 flex flex-col min-h-0;
|
||||
}
|
||||
.script-section > :first-child {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.script-section .content-with-history {
|
||||
@apply flex-1 h-auto min-h-0;
|
||||
}
|
||||
.script-workspace-page .workspace-split > :not(.script-section) {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.script-section .n-tabs-tab__label {
|
||||
@apply inline-flex items-center gap-2;
|
||||
}
|
||||
.script-section .content-with-history.history-hidden {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[minmax(0,_1fr)];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { nextTick } from 'vue'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import EpisodeReader from './EpisodeReader.vue'
|
||||
import type { Episode } from '../projects/types'
|
||||
|
||||
const episodes: Episode[] = [
|
||||
{ episode: 1, title: '来信', summary: '第一集摘要', content: '第一场:旧书店。', conflict: '信件失踪' },
|
||||
{ episode: 2, title: '雨夜', content: '<script>不要执行模型内容</script>', hook: '门外有人' },
|
||||
{ episode: 3, title: '重逢', content: '' }
|
||||
]
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** happy-dom 不计算排版;注入明确的容器几何,但保持真实 NScrollbar 和原生 scroll 事件路径。 */
|
||||
async function mountReader() {
|
||||
wrapper = mount(EpisodeReader, { props: { episodes } })
|
||||
const reader = wrapper.get<HTMLElement>('.reader-scroll .n-scrollbar-container').element
|
||||
const directory = wrapper.get<HTMLElement>('.reader-directory-scroll .n-scrollbar-container').element
|
||||
const tops: Record<number, number> = { 1: 32, 2: 700, 3: 1480 }
|
||||
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 400 })
|
||||
Object.defineProperty(directory, 'clientHeight', { configurable: true, value: 164 })
|
||||
const originalRect = HTMLElement.prototype.getBoundingClientRect
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
|
||||
if (this === reader) return new DOMRect(0, 100, 600, reader.clientHeight)
|
||||
if (this === directory) return new DOMRect(0, 50, 200, directory.clientHeight)
|
||||
if (this.matches('.reader-chapter')) {
|
||||
return new DOMRect(0, 100 + tops[Number(this.dataset.episode)]! - reader.scrollTop, 600, 600)
|
||||
}
|
||||
if (this.matches('.reader-episode-link')) {
|
||||
return new DOMRect(0, 58 + (Number(this.dataset.episode) - 1) * 72 - directory.scrollTop, 180, 64)
|
||||
}
|
||||
return originalRect.call(this)
|
||||
})
|
||||
const readerScroll = vi.spyOn(reader, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
|
||||
if (typeof options !== 'object') return
|
||||
// 模拟浏览器边界:末集最小高度及上下留白让其标题能抵达阅读线。
|
||||
reader.scrollTop = Math.max(0, Math.min(options.top ?? 0, Math.max(...Object.values(tops)) - 32))
|
||||
reader.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
const directoryScroll = vi.spyOn(directory, 'scrollTo').mockImplementation((options?: ScrollToOptions | number) => {
|
||||
if (typeof options !== 'object') return
|
||||
directory.scrollTop = Math.max(0, options.top ?? 0)
|
||||
directory.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
await wrapper.setProps({ episodes: [...episodes] })
|
||||
return { reader, directory, tops, readerScroll, directoryScroll }
|
||||
}
|
||||
|
||||
/** 获取目录中的唯一当前集,避免只验证按钮类名而漏掉无障碍状态。 */
|
||||
function currentEpisode() {
|
||||
const current = wrapper!.findAll('.reader-episode-link[aria-current="location"]')
|
||||
expect(current).toHaveLength(1)
|
||||
return Number(current[0]!.attributes('data-episode'))
|
||||
}
|
||||
|
||||
describe('连续剧本阅读器', () => {
|
||||
it('正文容器靠左并保留目录间距,宽屏不再使用自动外边距居中', () => {
|
||||
// DOM 测试不计算布局;保护正文定位样式,目录联动仍由后续交互测试验证。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
const content = css.match(/\.reader-content\s*\{([^}]+)\}/)![1]!
|
||||
expect(content).toContain('max-width: 900px')
|
||||
expect(content).toContain('margin-inline: 0')
|
||||
expect(content).toContain('padding: 32px')
|
||||
expect(content).toContain('text-align: left')
|
||||
expect(content).not.toContain('auto')
|
||||
expect(css).toMatch(/@media \(max-width: 600px\)[\s\S]*?\.reader-content\s*\{\s*padding-inline:\s*18px/)
|
||||
})
|
||||
|
||||
it('按编号连续渲染全部剧集、元数据和空正文,标题不截断,模型内容只按文本显示', () => {
|
||||
const reversed = episodes.toReversed()
|
||||
wrapper = mount(EpisodeReader, { props: { episodes: reversed } })
|
||||
expect(wrapper.findAll('.reader-chapter').map(item => Number(item.attributes('data-episode')))).toEqual([
|
||||
1, 2, 3
|
||||
])
|
||||
expect(reversed[0]?.episode).toBe(3)
|
||||
expect(wrapper.findAll('.reader-chapter .script-body')).toHaveLength(2)
|
||||
expect(wrapper.get('.script-summary').text()).toBe('第一集摘要')
|
||||
expect(wrapper.text()).toContain('信件失踪')
|
||||
expect(wrapper.text()).toContain('门外有人')
|
||||
expect(wrapper.text()).toContain('本集正文尚未写入')
|
||||
expect(wrapper.text()).toContain('<script>不要执行模型内容</script>')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.n-scrollbar-container')).toHaveLength(2)
|
||||
expect(wrapper.get('.reader-scroll [role="region"]').attributes('tabindex')).toBe('0')
|
||||
expect(wrapper.get('.reader-episode-link').attributes('aria-controls')).toBe(
|
||||
wrapper.get('.reader-chapter').attributes('id')
|
||||
)
|
||||
})
|
||||
|
||||
it('点击目录通过 NScrollbar 定位首集、中间集和短末集,不滚动 window', async () => {
|
||||
const { reader, readerScroll } = await mountReader()
|
||||
const pageScroll = vi.spyOn(window, 'scrollTo')
|
||||
for (const [episode, top] of [
|
||||
[2, 668],
|
||||
[3, 1448],
|
||||
[1, 0]
|
||||
] as const) {
|
||||
await wrapper!.get(`.reader-episode-link[data-episode="${episode}"]`).trigger('click')
|
||||
expect(readerScroll).toHaveBeenLastCalledWith(expect.objectContaining({ top, behavior: 'auto' }))
|
||||
expect(reader.scrollTop).toBe(top)
|
||||
expect(currentEpisode()).toBe(episode)
|
||||
}
|
||||
expect(pageScroll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('正文双向滚动同步当前集,目录项离开可见区时自动露出', async () => {
|
||||
const { reader, directory, directoryScroll } = await mountReader()
|
||||
reader.scrollTop = 1450
|
||||
reader.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
expect(currentEpisode()).toBe(3)
|
||||
expect(directoryScroll).toHaveBeenCalled()
|
||||
expect(directory.scrollTop).toBeGreaterThan(0)
|
||||
reader.scrollTop = 0
|
||||
reader.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
expect(currentEpisode()).toBe(1)
|
||||
expect(directory.scrollTop).toBe(0)
|
||||
})
|
||||
|
||||
it('同集内阅读不反复挪动目录,也不因目录滚动而改变正文', async () => {
|
||||
const { reader, directory, directoryScroll, readerScroll } = await mountReader()
|
||||
directory.scrollTop = 60
|
||||
directory.dispatchEvent(new Event('scroll'))
|
||||
reader.scrollTop = 120
|
||||
reader.dispatchEvent(new Event('scroll'))
|
||||
await nextTick()
|
||||
expect(currentEpisode()).toBe(1)
|
||||
expect(directory.scrollTop).toBe(60)
|
||||
expect(directoryScroll).not.toHaveBeenCalled()
|
||||
expect(readerScroll).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('后台刷新与追加剧集不销毁正文或跳回首集,新增集可以定位', async () => {
|
||||
const { reader, tops } = await mountReader()
|
||||
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
|
||||
const chapter = wrapper!.get('.reader-chapter[data-episode="2"]').element
|
||||
tops[4] = 2200
|
||||
await wrapper!.setProps({
|
||||
episodes: [...episodes.map(item => ({ ...item })), { episode: 4, title: '回家', content: '结局' }]
|
||||
})
|
||||
expect(reader.scrollTop).toBe(668)
|
||||
expect(currentEpisode()).toBe(2)
|
||||
expect(wrapper!.get('.reader-chapter[data-episode="2"]').element).toBe(chapter)
|
||||
await wrapper!.get('.reader-episode-link[data-episode="4"]').trigger('click')
|
||||
expect(reader.scrollTop).toBe(2168)
|
||||
expect(currentEpisode()).toBe(4)
|
||||
})
|
||||
|
||||
it('内容尺寸和可见性变化重新测量,卸载释放尺寸监听', async () => {
|
||||
const observers: TestObserver[] = []
|
||||
/** 仅模拟尺寸通知;断言仍走真实组件挂载与卸载生命周期。 */
|
||||
class TestObserver {
|
||||
targets = new Set<Element>()
|
||||
constructor(readonly callback: ResizeObserverCallback) {
|
||||
observers.push(this)
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.targets.add(target)
|
||||
}
|
||||
unobserve(target: Element) {
|
||||
this.targets.delete(target)
|
||||
}
|
||||
disconnect = vi.fn<() => void>(() => this.targets.clear())
|
||||
notify() {
|
||||
this.callback([], this as unknown as ResizeObserver)
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', TestObserver)
|
||||
const { reader, tops } = await mountReader()
|
||||
const observer = observers.find(item => item.targets.has(wrapper!.get('.reader-content').element))!
|
||||
expect(observer).toBeDefined()
|
||||
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
|
||||
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 0 })
|
||||
observer.notify()
|
||||
await nextTick()
|
||||
expect(currentEpisode()).toBe(2)
|
||||
Object.defineProperty(reader, 'clientHeight', { configurable: true, value: 500 })
|
||||
tops[2] = 500
|
||||
observer.notify()
|
||||
await nextTick()
|
||||
expect(wrapper!.get('.reader-content').attributes('style')).toContain('--reader-height: 500px')
|
||||
await wrapper!.get('.reader-episode-link[data-episode="2"]').trigger('click')
|
||||
expect(reader.scrollTop).toBe(468)
|
||||
wrapper!.unmount()
|
||||
wrapper = undefined
|
||||
expect(observer.disconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -166,3 +166,106 @@ onBeforeUnmount(() => observer?.disconnect())
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.script-summary {
|
||||
@apply text-xs text-muted leading-[1.9] pt-[17px] px-0 pb-[22px] mb-[23px];
|
||||
}
|
||||
.script-body {
|
||||
@apply whitespace-pre-wrap wrap-anywhere text-sm leading-[2.15] text-ink;
|
||||
}
|
||||
.script-notes {
|
||||
@apply mt-9 pt-5 text-xs;
|
||||
}
|
||||
.script-notes dt {
|
||||
@apply text-ink font-medium mb-1.5;
|
||||
}
|
||||
.script-notes dd {
|
||||
@apply text-muted leading-[1.8] mb-[15px];
|
||||
}
|
||||
.episode-reader {
|
||||
@apply grid grid-cols-[clamp(190px,_22%,_240px)_minmax(0,_1fr)] h-full min-h-0 min-w-0 overflow-hidden;
|
||||
container-type: inline-size;
|
||||
}
|
||||
.reader-directory {
|
||||
@apply flex flex-col min-h-0 min-w-0 bg-(--app-subtle);
|
||||
}
|
||||
.reader-directory-heading {
|
||||
@apply flex justify-between items-center shrink-0 gap-2 pt-[22px] px-5 pb-3.5 text-[13px];
|
||||
}
|
||||
.reader-directory-heading h3 {
|
||||
@apply font-semibold;
|
||||
}
|
||||
.reader-directory-heading span {
|
||||
@apply text-[11px] text-muted whitespace-nowrap;
|
||||
}
|
||||
.reader-directory-scroll {
|
||||
@apply flex-1;
|
||||
}
|
||||
.reader-directory-content {
|
||||
@apply flex flex-col gap-1.5 pt-1 px-0 pb-[18px];
|
||||
}
|
||||
.n-button.reader-episode-link {
|
||||
@apply shrink-0 w-full h-auto min-h-[64px] py-3 px-3.5 rounded-none text-muted whitespace-normal text-left;
|
||||
}
|
||||
.n-button.reader-episode-link .n-button__content {
|
||||
@apply flex flex-col items-start gap-[5px] w-full min-w-0;
|
||||
}
|
||||
.reader-episode-label {
|
||||
@apply text-[13px] font-semibold leading-normal;
|
||||
}
|
||||
.reader-episode-title {
|
||||
@apply text-xs leading-[1.6] wrap-anywhere;
|
||||
}
|
||||
.n-button.reader-episode-link.active {
|
||||
@apply bg-(--app-selected) text-accent shadow-[inset_3px_0_var(--app-accent)];
|
||||
}
|
||||
.reader-page {
|
||||
@apply min-w-0 min-h-0 overflow-hidden bg-(--app-surface);
|
||||
}
|
||||
.reader-scroll .n-scrollbar-container:focus-visible {
|
||||
@apply outline-[2px_solid_var(--app-accent)] outline-offset-[-3px];
|
||||
}
|
||||
.reader-content {
|
||||
@apply mx-0 p-8 text-left;
|
||||
/* 正文紧邻目录左对齐,保留阅读行宽,不在宽屏内容区居中。 */
|
||||
max-width: 900px;
|
||||
}
|
||||
.reader-chapter + .reader-chapter {
|
||||
@apply mt-12 pt-9;
|
||||
}
|
||||
.reader-chapter:last-child {
|
||||
/* 末集较短时仍可定位到阅读线,不必把倒数第二集误认为当前集。 */
|
||||
min-height: max(160px, calc(var(--reader-height) - 64px));
|
||||
}
|
||||
.reader-chapter-heading {
|
||||
@apply mb-6;
|
||||
}
|
||||
.reader-chapter-heading p {
|
||||
@apply text-muted text-[13px] font-medium mb-2.5;
|
||||
}
|
||||
.reader-chapter-heading h2 {
|
||||
@apply text-[clamp(18px,2cqw,24px)] font-semibold leading-[1.6] wrap-anywhere;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.episode-reader {
|
||||
@apply grid-cols-[180px_minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.episode-reader {
|
||||
@apply grid-cols-[112px_minmax(0,_1fr)];
|
||||
}
|
||||
.reader-directory-heading {
|
||||
@apply pt-4 px-3 pb-3 flex-wrap;
|
||||
}
|
||||
.n-button.reader-episode-link {
|
||||
@apply py-3 px-2.5;
|
||||
}
|
||||
.reader-content {
|
||||
@apply px-[18px];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -619,3 +619,99 @@ watch(
|
||||
/>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.production-controls {
|
||||
@apply grid grid-cols-[minmax(220px,_1.6fr)_minmax(110px,_0.5fr)_minmax(230px,_1fr)_auto] items-end gap-[18px];
|
||||
}
|
||||
.production-pipeline {
|
||||
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3.5;
|
||||
}
|
||||
.production-pipeline-card {
|
||||
@apply p-5;
|
||||
}
|
||||
.production-stats {
|
||||
@apply grid grid-cols-[repeat(3,_1fr)] gap-2;
|
||||
}
|
||||
.production-stats div,
|
||||
.production-status-grid div {
|
||||
@apply p-2.5 rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.production-stats dt,
|
||||
.production-status-grid dt {
|
||||
@apply text-muted text-[10px];
|
||||
}
|
||||
.production-stats dd,
|
||||
.production-status-grid dd {
|
||||
@apply mt-[5px] text-[13px] font-mono;
|
||||
}
|
||||
.production-status-grid {
|
||||
@apply grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-2.5;
|
||||
}
|
||||
.production-workspace {
|
||||
@apply grid grid-cols-[220px_minmax(0,_1fr)] items-start overflow-hidden;
|
||||
}
|
||||
.production-shot-list {
|
||||
@apply max-h-[760px] overflow-hidden bg-(--app-surface) py-2;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.production-pipeline {
|
||||
@apply grid-cols-[1fr];
|
||||
}
|
||||
.production-controls {
|
||||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.production-controls,
|
||||
.production-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.production-shot-list {
|
||||
@apply flex max-h-none overflow-hidden;
|
||||
border-right: none;
|
||||
}
|
||||
.production-status-grid {
|
||||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||||
}
|
||||
}
|
||||
.workspace-inline-status {
|
||||
@apply flex items-center flex-wrap justify-between gap-y-2 gap-x-4 py-[7px] px-3 shrink-0 rounded-none bg-(--app-subtle) text-xs;
|
||||
}
|
||||
.production-pipeline-card > .confirm-action {
|
||||
@apply mt-4 mb-1;
|
||||
}
|
||||
.production-workspace {
|
||||
@apply flex-1 min-h-0 items-stretch overflow-hidden;
|
||||
}
|
||||
.production-workspace {
|
||||
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
|
||||
}
|
||||
.production-shot-list {
|
||||
@apply max-h-none overflow-hidden min-h-0;
|
||||
}
|
||||
.production-workspace > article {
|
||||
@apply overflow-hidden min-h-0 overscroll-contain;
|
||||
}
|
||||
.production-shot-list-content {
|
||||
@apply gap-3 pt-0 px-0 pb-3;
|
||||
}
|
||||
.production-shot-list {
|
||||
@apply p-0;
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.production-controls {
|
||||
@apply grid-cols-[minmax(130px,_1fr)_90px];
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.production-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
|
||||
}
|
||||
.production-pipeline {
|
||||
@apply grid-cols-[1fr];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectContextKey } from '../projects/context'
|
||||
import { testProjectContext } from '../../testing/project-context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import { formFixture } from '../subject-images/testing/fixtures'
|
||||
import { directionsResult } from '../storyboard/testing/fixtures'
|
||||
import { checkPipeline, useAdvancedProduction } from './useAdvancedProduction'
|
||||
import { useProduction } from './useProduction'
|
||||
import { getProductionSession } from './model'
|
||||
import AdvancedProduction from './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 })
|
||||
})
|
||||
})
|
||||
@@ -259,3 +259,11 @@ async function submit() {
|
||||
</p>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.quality-fields {
|
||||
@apply grid grid-cols-[repeat(auto-fit,_minmax(min(100%,_180px),_1fr))] items-start gap-4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -140,3 +140,17 @@ function exportResult() {
|
||||
</NCollapse>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.quality-result {
|
||||
@apply mt-6 p-4 bg-(--app-subtle);
|
||||
}
|
||||
.quality-result-row {
|
||||
@apply flex flex-wrap items-center gap-y-2 gap-x-3 mt-2.5 p-3 bg-(--app-control) text-xs wrap-anywhere;
|
||||
}
|
||||
.quality-json {
|
||||
@apply mt-4 whitespace-pre-wrap wrap-anywhere text-[11px] leading-[1.7];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -523,3 +523,46 @@ async function inspectSpecs() {
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.production-stage {
|
||||
@apply py-5 px-0;
|
||||
}
|
||||
.production-stage:first-of-type {
|
||||
@apply border-t-0;
|
||||
}
|
||||
.production-stage-heading {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
.keyframe-grid {
|
||||
@apply grid grid-cols-[repeat(3,_minmax(0,_1fr))] gap-3;
|
||||
}
|
||||
.keyframe-card {
|
||||
@apply overflow-hidden rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.keyframe-card .asset-image {
|
||||
@apply aspect-video rounded-none;
|
||||
}
|
||||
.video-record {
|
||||
@apply flex overflow-hidden rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.production-video {
|
||||
@apply w-[min(44%,390px)] min-h-[170px] bg-ink object-contain;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.keyframe-grid {
|
||||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||||
}
|
||||
.video-record {
|
||||
@apply block;
|
||||
}
|
||||
.production-video {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
.production-video {
|
||||
@apply bg-[#080808];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
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 './components/KeyframeDialog.vue'
|
||||
import { productionApi } from './api'
|
||||
import {
|
||||
isActiveVideo,
|
||||
issueLabel,
|
||||
primaryKeyframe,
|
||||
primaryVideo,
|
||||
productionStatusLabel,
|
||||
validOptionalSize
|
||||
} from './model'
|
||||
import { keyframeFixture, videoFixture } from './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 }])
|
||||
})
|
||||
})
|
||||
@@ -1,515 +0,0 @@
|
||||
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 './api'
|
||||
import { qualityApi } from './quality-api'
|
||||
import {
|
||||
allowedTextLines,
|
||||
qualityKey,
|
||||
qualitySession,
|
||||
qualityTargets,
|
||||
savedVideoValidation,
|
||||
videoRepairInfo,
|
||||
validQualityInput
|
||||
} from './quality'
|
||||
import type { KeyframeReadiness } from './types'
|
||||
import type { QualityInput, QualityTarget } from './quality.types'
|
||||
import { useQuality } from './useQuality'
|
||||
import { keyframeFixture, videoFixture } from './testing/fixtures'
|
||||
import QualityDialog from './components/QualityDialog.vue'
|
||||
import QualityResult from './components/QualityResult.vue'
|
||||
import ModelCapabilities from './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)
|
||||
})
|
||||
})
|
||||
@@ -80,3 +80,50 @@ onScopeDispose(() => {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.project-frame {
|
||||
@apply flex flex-col h-full min-h-0 overflow-hidden;
|
||||
}
|
||||
.project-header {
|
||||
@apply shrink-0 py-[9px] px-5 bg-(--app-surface);
|
||||
}
|
||||
.project-header .n-page-header__main,
|
||||
.project-header .n-page-header__title {
|
||||
@apply min-w-0 overflow-hidden;
|
||||
}
|
||||
.project-title {
|
||||
@apply max-w-full text-base font-semibold;
|
||||
}
|
||||
.project-notices.n-scrollbar {
|
||||
@apply shrink-0 h-auto max-h-[100px] overflow-hidden;
|
||||
}
|
||||
.project-notices-content {
|
||||
@apply py-2 px-6 grid gap-[5px];
|
||||
}
|
||||
.project-notices .n-alert {
|
||||
@apply py-[7px] px-3 text-xs;
|
||||
}
|
||||
.project-view {
|
||||
@apply flex-1 min-h-0 overflow-hidden;
|
||||
}
|
||||
.project-header .n-page-header-wrapper {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.project-access-gate {
|
||||
@apply h-full;
|
||||
}
|
||||
.workspace-loading {
|
||||
@apply grid place-content-center h-full;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.project-header {
|
||||
@apply py-2 px-3;
|
||||
}
|
||||
.project-title {
|
||||
@apply text-sm;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -140,3 +140,73 @@ function clearFilters() {
|
||||
</p>
|
||||
</WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.page-heading {
|
||||
@apply flex items-center justify-between gap-6 mb-7;
|
||||
}
|
||||
.page-heading h1 {
|
||||
@apply mt-[9px] mx-0 mb-0 text-[27px] font-semibold tracking-[-0.035em] leading-[1.4];
|
||||
}
|
||||
.page-description {
|
||||
@apply mt-[9px] text-muted text-xs leading-[1.7];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.page-heading {
|
||||
@apply items-start flex-col gap-4;
|
||||
}
|
||||
.page-heading h1 {
|
||||
@apply text-[23px];
|
||||
}
|
||||
}
|
||||
.project-table-area {
|
||||
@apply flex-1 min-h-0;
|
||||
}
|
||||
.project-data-table {
|
||||
@apply h-full;
|
||||
}
|
||||
.project-data-table .n-data-table-base-table {
|
||||
@apply flex-1 min-h-0;
|
||||
}
|
||||
.project-index-heading {
|
||||
@apply m-0;
|
||||
}
|
||||
.project-index-heading h1 {
|
||||
@apply text-[22px] font-semibold;
|
||||
}
|
||||
.project-index-toolbar {
|
||||
@apply flex items-center justify-between flex-wrap gap-3 mb-1;
|
||||
}
|
||||
.project-search {
|
||||
@apply w-[240px];
|
||||
}
|
||||
.project-status-filters.n-radio-group {
|
||||
@apply flex flex-wrap h-auto min-h-(--n-height) gap-y-1 gap-x-px;
|
||||
}
|
||||
.project-status-filters .n-radio-group__splitor {
|
||||
@apply hidden;
|
||||
}
|
||||
.project-status-filters .n-radio-button {
|
||||
@apply min-w-[76px] px-[18px] text-center;
|
||||
}
|
||||
.project-name-cell {
|
||||
@apply block min-w-0;
|
||||
}
|
||||
.project-name-cell strong,
|
||||
.project-name-cell small {
|
||||
@apply block overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
.project-name-cell small {
|
||||
@apply text-muted mt-1 text-[11px];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.project-search {
|
||||
@apply w-full;
|
||||
}
|
||||
.project-index-toolbar .n-radio-group {
|
||||
@apply w-full;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import { h } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import App from '../../App.vue'
|
||||
import ProjectLayout from './ProjectLayout.vue'
|
||||
import { isProjectComplete } from './access'
|
||||
import { projectsApi } from './api'
|
||||
import type { ProjectDetail, ProjectStatus } from './types'
|
||||
|
||||
const downstream = ['breakdown', 'visual-style', 'subject-identity', 'subject-images', 'storyboard', 'production']
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
localStorage.clear()
|
||||
vi.useRealTimers()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
/** 用已有剧集模拟中途生成的项目,完成与否必须取 status 而非数组长度。 */
|
||||
function project(id: string, status: ProjectStatus): ProjectDetail {
|
||||
return {
|
||||
id,
|
||||
status,
|
||||
title: '访问限制测试',
|
||||
topic: '',
|
||||
style: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
episodes: [{ episode: 1, title: '部分剧集', content: '已经写入的内容' }],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
}
|
||||
}
|
||||
|
||||
/** 真实项目布局与侧栏,子工作区以挂载探针代替,防止测试发起实际生成请求。 */
|
||||
async function openProject(initialPath: string) {
|
||||
const mounted = vi.fn<(path: string) => void>()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/projects', component: { render: () => h('div', '项目列表') } },
|
||||
{
|
||||
path: '/projects/:projectId',
|
||||
component: ProjectLayout,
|
||||
children: ['create-drama', ...downstream].map(path => ({
|
||||
path,
|
||||
component: {
|
||||
setup() {
|
||||
mounted(path)
|
||||
return () => h('div', { class: 'workspace-probe' }, path)
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
]
|
||||
})
|
||||
await router.push(initialPath)
|
||||
wrapper = mount(App, { attachTo: document.body, global: { plugins: [router] } })
|
||||
await flushPromises()
|
||||
return { router, mounted }
|
||||
}
|
||||
|
||||
describe('剧本完成前的下游访问限制', () => {
|
||||
it('进入图库暂停父级轮询,返回其他工作区恢复定时更新', async () => {
|
||||
vi.useFakeTimers()
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('manual-gallery', 'completed'))
|
||||
const checkpoints = vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router } = await openProject('/projects/manual-gallery/production')
|
||||
await router.push('/projects/manual-gallery/subject-images')
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(detail).toHaveBeenCalledTimes(1)
|
||||
expect(checkpoints).toHaveBeenCalledTimes(1)
|
||||
await router.push('/projects/manual-gallery/production')
|
||||
await flushPromises()
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
expect(checkpoints).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('图库直接链接只读取一次项目,未完成时可手动刷新解锁', async () => {
|
||||
vi.useFakeTimers()
|
||||
let status: ProjectStatus = 'generating'
|
||||
const detail = vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
await openProject('/projects/manual-gate/subject-images')
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(detail).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper!.get('.project-access-gate').text()).not.toContain('状态会自动更新')
|
||||
status = 'completed'
|
||||
const refresh = wrapper!.findAll('button').find(button => button.text() === '刷新项目状态')!
|
||||
await refresh.trigger('click')
|
||||
await flushPromises()
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('subject-images')
|
||||
})
|
||||
|
||||
it.each(['draft', 'generating', 'need_review', 'failed'] as const)(
|
||||
'%s 不解锁导航,也不挂载直接链接对应的工作区',
|
||||
async status => {
|
||||
vi.spyOn(projectsApi, 'detail').mockResolvedValue(project('unfinished', status))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router, mounted } = await openProject('/projects/unfinished/production')
|
||||
for (const path of downstream) {
|
||||
await router.push(`/projects/unfinished/${path}`)
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作')
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
expect(wrapper!.find(`.n-menu a[href="/projects/unfinished/${path}"]`).exists()).toBe(false)
|
||||
}
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
expect(wrapper!.findAll('.n-menu [aria-disabled="true"]')).toHaveLength(6)
|
||||
await wrapper!.get('.project-access-gate button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.path).toBe('/projects/unfinished/create-drama')
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('create-drama')
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('create-drama')
|
||||
}
|
||||
)
|
||||
|
||||
it('轮询完成状态自动解锁;重新变为待审核时撤下下游面板', async () => {
|
||||
vi.useFakeTimers()
|
||||
let status: ProjectStatus = 'generating'
|
||||
vi.spyOn(projectsApi, 'detail').mockImplementation(async id => project(id, status))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { mounted } = await openProject('/projects/polling/production')
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
status = 'completed'
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.workspace-probe').text()).toBe('production')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
|
||||
status = 'need_review'
|
||||
await vi.advanceTimersByTimeAsync(6000)
|
||||
await flushPromises()
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
expect(wrapper!.get('.project-access-gate').text()).toContain('请先完成剧本创作')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||
})
|
||||
|
||||
it('切换项目与首次读取期间不能沿用上一个已完成项目的权限', async () => {
|
||||
let resolveSecond!: (value: ProjectDetail) => void
|
||||
const pending = new Promise<ProjectDetail>(resolve => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
vi.spyOn(projectsApi, 'detail').mockImplementation(async id =>
|
||||
id === 'first' ? project(id, 'completed') : pending
|
||||
)
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { router, mounted } = await openProject('/projects/first/production')
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(8)
|
||||
await router.push('/projects/second/production')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
resolveSecond(project('second', 'draft'))
|
||||
await flushPromises()
|
||||
expect(wrapper!.find('.workspace-probe').exists()).toBe(false)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('production')
|
||||
await router.push('/projects')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('读取失败保持锁定;错误区重试成功后解锁,页头不恢复标签与刷新按钮', async () => {
|
||||
const detail = vi
|
||||
.spyOn(projectsApi, 'detail')
|
||||
.mockRejectedValueOnce(new Error('项目读取失败'))
|
||||
.mockResolvedValue(project('retry', 'completed'))
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const { mounted } = await openProject('/projects/retry/storyboard')
|
||||
expect(mounted).not.toHaveBeenCalled()
|
||||
expect(wrapper!.findAll('.n-menu a')).toHaveLength(2)
|
||||
expect(wrapper!.find('.project-header .n-button').exists()).toBe(false)
|
||||
expect(wrapper!.find('.project-header .n-tag').exists()).toBe(false)
|
||||
await wrapper!.get('.project-notices button').trigger('click')
|
||||
await flushPromises()
|
||||
expect(detail).toHaveBeenCalledTimes(2)
|
||||
expect(mounted).toHaveBeenCalledExactlyOnceWith('storyboard')
|
||||
})
|
||||
|
||||
it('不存在、未知状态或项目 ID 不匹配时默认锁定', () => {
|
||||
expect(isProjectComplete(null, 'p')).toBe(false)
|
||||
expect(isProjectComplete({ id: 'other', status: 'completed' }, 'p')).toBe(false)
|
||||
expect(isProjectComplete({ id: 'p', status: 'unknown' as ProjectStatus }, 'p')).toBe(false)
|
||||
expect(isProjectComplete({ id: 'p', status: 'completed' }, 'p')).toBe(true)
|
||||
})
|
||||
|
||||
it('配置复选框居中对齐,输入表面随面板背景分层而非增加边框', () => {
|
||||
// 保护布局契约;实际像素对齐仍需浏览器视觉验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.n-checkbox\.control-row-checkbox\s*\{[^}]*align-items:\s*center;[^}]*min-height:\s*34px;[^}]*padding-block:\s*0/
|
||||
)
|
||||
expect(css).toMatch(/\.app-dialog\s*\{[^}]*--app-field:\s*var\(--app-control\)/)
|
||||
expect(readFileSync('src/styles.css', 'utf8')).toMatch(/\.panel\s*\{[^}]*--app-field:\s*var\(--app-control\)/)
|
||||
for (const path of [
|
||||
'production/ProductionPage.vue',
|
||||
'storyboard/StoryboardPage.vue',
|
||||
'subject-identity/SubjectIdentityPage.vue',
|
||||
'subject-images/SubjectImagesPage.vue'
|
||||
]) {
|
||||
const source = readFileSync(`src/features/${path}`, 'utf8')
|
||||
expect(source).toContain('control-row-checkbox')
|
||||
expect(source).not.toMatch(/<NCheckbox\b[^>]*class="[^"]*pb-[23]/)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -418,3 +418,73 @@ watch(
|
||||
></WorkspacePage
|
||||
>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.storyboard-controls {
|
||||
@apply grid grid-cols-[minmax(180px,_1fr)_100px_145px_auto] gap-[18px] items-end;
|
||||
}
|
||||
.generation-row {
|
||||
@apply flex items-center justify-between gap-5 pt-5 mt-5;
|
||||
}
|
||||
.generation-row > div:last-child {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
@apply grid grid-cols-[220px_minmax(0,_1fr)] overflow-hidden;
|
||||
}
|
||||
.shot-list {
|
||||
@apply max-h-[1000px] overflow-hidden bg-(--app-surface) pt-0 px-2 pb-5;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.generation-row {
|
||||
@apply items-start flex-col gap-3;
|
||||
}
|
||||
.storyboard-controls {
|
||||
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.storyboard-controls {
|
||||
@apply grid-cols-[minmax(0,_1fr)] gap-3;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.shot-list {
|
||||
@apply max-h-[240px];
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
.storyboard-workspace-page .storyboard-workspace {
|
||||
@apply flex-1 h-auto min-h-0 overflow-hidden;
|
||||
}
|
||||
.storyboard-coverage {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.storyboard-workspace {
|
||||
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
|
||||
}
|
||||
.storyboard-workspace {
|
||||
@apply h-[clamp(360px,65dvh,850px)] min-h-0;
|
||||
}
|
||||
.storyboard-workspace > article,
|
||||
.storyboard-workspace .shot-list {
|
||||
@apply overflow-hidden min-h-0 h-full max-h-none;
|
||||
}
|
||||
.shot-list-content {
|
||||
@apply gap-3 pt-0 px-0 pb-3;
|
||||
}
|
||||
.shot-list {
|
||||
@apply p-0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.storyboard-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[184px_minmax(0,_1fr)];
|
||||
}
|
||||
.storyboard-controls {
|
||||
@apply grid-cols-[minmax(0,_1fr)_minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -55,3 +55,35 @@ const groups = computed(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.directory-group-heading {
|
||||
@apply sticky top-0 z-[1] flex items-center justify-between gap-3 shrink-0 py-2.5 px-3.5 bg-(--app-control) font-mono font-semibold text-[11px] leading-[1.6] text-ink;
|
||||
}
|
||||
.directory-group-count {
|
||||
@apply font-normal text-muted;
|
||||
}
|
||||
.beat-directory-group {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.beat-directory-items {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
.directory-mobile-beat {
|
||||
@apply hidden;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.directory-group-heading {
|
||||
@apply hidden;
|
||||
}
|
||||
.beat-directory-group,
|
||||
.beat-directory-items {
|
||||
display: contents;
|
||||
}
|
||||
.directory-mobile-beat {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -214,3 +214,20 @@ function exportResult(kind: 'spec' | 'prompt') {
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.alert {
|
||||
@apply py-3 px-[15px] rounded-none bg-(--app-subtle) text-ink text-xs leading-[1.8] wrap-anywhere;
|
||||
}
|
||||
.reference-grid {
|
||||
@apply grid grid-cols-[repeat(auto-fill,_minmax(150px,_1fr))] gap-3.5;
|
||||
}
|
||||
.reference-card {
|
||||
@apply bg-(--app-subtle) rounded-none overflow-hidden;
|
||||
}
|
||||
.storyboard-json {
|
||||
@apply p-4 rounded-none bg-(--app-subtle) font-mono text-[11px] leading-[1.9];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import BeatShotDirectory from './components/BeatShotDirectory.vue'
|
||||
import { designedShot } from './testing/fixtures'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => wrapper?.unmount())
|
||||
|
||||
describe('Beat 两级镜头目录', () => {
|
||||
it('按 Beat 和镜号排序,分组只出现一次并显示镜数,不改变原数组', () => {
|
||||
const shots = [
|
||||
{ ...designedShot('b2-s1'), beatNo: 2 },
|
||||
{ ...designedShot('b1-s2'), shotNo: 2 },
|
||||
designedShot('b1-s1')
|
||||
]
|
||||
wrapper = mount(BeatShotDirectory, {
|
||||
props: { shots, activeId: 'b1-s1', itemClass: 'shot-link' },
|
||||
slots: { meta: '<span>设计已保存</span>' }
|
||||
})
|
||||
const groups = wrapper.findAll('.beat-directory-group')
|
||||
expect(groups).toHaveLength(2)
|
||||
expect(groups[0]!.get('h4').text()).toContain('BEAT 01')
|
||||
expect(groups[0]!.get('.directory-group-count').text()).toBe('2 镜')
|
||||
expect(groups[1]!.get('h4').text()).toContain('BEAT 02')
|
||||
expect(groups[1]!.get('.directory-group-count').text()).toBe('1 镜')
|
||||
expect(wrapper.findAll('.directory-shot-number').map(item => item.text())).toEqual([
|
||||
'镜头 01',
|
||||
'镜头 02',
|
||||
'镜头 01'
|
||||
])
|
||||
expect(wrapper.findAll('.directory-item-meta').every(item => item.text() === '设计已保存')).toBe(true)
|
||||
expect(groups[0]!.attributes('aria-labelledby')).toBe(groups[0]!.get('h4').attributes('id'))
|
||||
expect(shots.map(shot => shot.shotId)).toEqual(['b2-s1', 'b1-s2', 'b1-s1'])
|
||||
})
|
||||
|
||||
it('不同 Beat 的同号镜头按正式 ID 选择,数据刷新后选中项保持不变', async () => {
|
||||
const shots = [designedShot('b1-s1'), { ...designedShot('b2-s1'), beatNo: 2 }]
|
||||
wrapper = mount(BeatShotDirectory, { props: { shots, activeId: 'b1-s1', itemClass: 'production-shot-link' } })
|
||||
await wrapper.findAll('.production-shot-link')[1]!.trigger('click')
|
||||
expect(wrapper.emitted('select')).toEqual([['b2-s1']])
|
||||
await wrapper.setProps({
|
||||
activeId: 'b2-s1',
|
||||
shots: [...shots, { ...designedShot('b2-s2'), beatNo: 2, shotNo: 2 }]
|
||||
})
|
||||
expect(wrapper.get('.selected').attributes('aria-label')).toBe('BEAT 2 · 镜头 1 · 来信')
|
||||
expect(wrapper.findAll('.selected')).toHaveLength(1)
|
||||
expect(wrapper.findAll('.directory-group-count')[1]!.text()).toBe('2 镜')
|
||||
})
|
||||
|
||||
it('组内吸顶与移动端归属提示分别保护,不在桌面重复展示 Beat', () => {
|
||||
// happy-dom 不计算吸顶位置,保护 CSS 边界,实际滚动仍需浏览器验收。
|
||||
const css = readFileSync('src/admin.css', 'utf8')
|
||||
expect(css).toMatch(
|
||||
/\.directory-group-heading\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*background:\s*var\(--app-control\)/
|
||||
)
|
||||
expect(css).toMatch(/\.directory-mobile-beat\s*\{\s*display:\s*none/)
|
||||
const mobile = css.slice(css.lastIndexOf('@media (max-width: 760px)'))
|
||||
expect(mobile).toMatch(/\.directory-mobile-beat\s*\{\s*display:\s*inline/)
|
||||
expect(mobile).toMatch(/\.beat-directory-group,\s*\.beat-directory-items\s*\{\s*display:\s*contents/)
|
||||
})
|
||||
})
|
||||
@@ -1,64 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { directionLabel, mergeDesignedShots, referenceImageUrl, storyboardPrerequisites } from './model'
|
||||
import { directionsResult, episodeShots, storyboardCheckpoint, visualStatesResult } from './testing/fixtures'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('分镜正式数据与生成依赖', () => {
|
||||
it('用 Shot ID 关联状态,用 Beat + Shot 编号补充描述,不串同编号镜头', () => {
|
||||
const directions = directionsResult('p')
|
||||
const states = visualStatesResult('p', 1, true)
|
||||
states.beats.reverse()
|
||||
states.beats[0]!.shots[0]!.visualState!.continuityNote = '第二个 Beat'
|
||||
const shots = mergeDesignedShots(directions, states, episodeShots())
|
||||
expect(shots.map(shot => [shot.shotId, shot.title, shot.visualState?.continuityNote])).toEqual([
|
||||
['shot-db-1-1', '第1集镜头1', '信封始终在右手'],
|
||||
['shot-db-1-2', '第1集镜头2', '第二个 Beat']
|
||||
])
|
||||
})
|
||||
|
||||
it('没有 Direction 的正式 Shot 仍可显示,不用 checkpoint 伪造数据库 ID', () => {
|
||||
const rows = mergeDesignedShots(directionsResult('p', 1, false), visualStatesResult('p'), episodeShots())
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows[0]?.direction).toBeNull()
|
||||
expect(mergeDesignedShots(null, null, episodeShots())).toEqual([])
|
||||
})
|
||||
|
||||
it('仅最新 Breakdown 决定生成前置条件,不能回退到历史完整快照', () => {
|
||||
const ready = storyboardCheckpoint()
|
||||
expect(storyboardPrerequisites([ready], 1)).toMatchObject({ directionEpisode: true, visualEpisode: true })
|
||||
expect(storyboardPrerequisites([ready], 3)).toMatchObject({ directionEpisode: false, visualEpisode: false })
|
||||
const latest = { ...ready, checkpointId: 'failed', createdAt: '2026-08-28T01:00:00Z', state: {} }
|
||||
expect(storyboardPrerequisites([latest, ready], 1)).toMatchObject({
|
||||
directionProject: false,
|
||||
visualProject: false
|
||||
})
|
||||
expect(storyboardPrerequisites([{ ...latest, workflowName: 'create-drama' }, ready], 1)).toMatchObject({
|
||||
directionProject: true
|
||||
})
|
||||
})
|
||||
|
||||
it('保留后端嵌套 Direction 与顶层 VisualState 的不同依赖', () => {
|
||||
const checkpoint = storyboardCheckpoint()
|
||||
delete checkpoint.state.breakdownResult
|
||||
expect(storyboardPrerequisites([checkpoint], 1)).toMatchObject({ directionEpisode: false, visualEpisode: true })
|
||||
checkpoint.state.subjectForms = []
|
||||
expect(storyboardPrerequisites([checkpoint], 1).visualEpisode).toBe(false)
|
||||
expect(directionLabel('future-camera-mode')).toBe('future-camera-mode')
|
||||
})
|
||||
|
||||
it('参考图只允许 http(s) 或后端 storage 地址,拦截不可信协议和路径逃逸', () => {
|
||||
vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.test/api')
|
||||
expect(referenceImageUrl('/storage/image.png')).toBe('https://api.example.test/storage/image.png')
|
||||
expect(referenceImageUrl('https://images.example.test/a.png')).toBe('https://images.example.test/a.png')
|
||||
for (const value of [
|
||||
'javascript:alert(1)',
|
||||
'data:image/svg+xml,anything',
|
||||
'//evil.test/img',
|
||||
'/storage/../api/projects',
|
||||
'/storage/\\evil.test/img'
|
||||
]) {
|
||||
expect(referenceImageUrl(value)).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -621,3 +621,117 @@ watch(
|
||||
@generate="generateCastingCandidate"
|
||||
/></WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.identity-workspace {
|
||||
@apply grid grid-cols-[240px_minmax(0,_1fr)] items-start gap-5;
|
||||
}
|
||||
.identity-subject-list {
|
||||
@apply max-h-[560px] overflow-hidden;
|
||||
}
|
||||
.casting-stats {
|
||||
@apply grid grid-cols-[repeat(5,_minmax(0,_1fr))] gap-2.5;
|
||||
}
|
||||
.casting-stats div {
|
||||
@apply py-2.5 px-3 rounded-none bg-(--app-subtle);
|
||||
}
|
||||
.casting-stats dt {
|
||||
@apply text-muted text-[10px];
|
||||
}
|
||||
.casting-stats dd {
|
||||
@apply mt-[5px] font-mono text-[13px];
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.identity-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
.identity-subject-list {
|
||||
@apply max-h-[220px];
|
||||
}
|
||||
.casting-stats {
|
||||
@apply grid-cols-[repeat(2,_minmax(0,_1fr))];
|
||||
}
|
||||
}
|
||||
.toolbar-type-filter {
|
||||
@apply w-[152px];
|
||||
}
|
||||
.identity-tools-intro {
|
||||
@apply mb-6;
|
||||
}
|
||||
.identity-workspace {
|
||||
@apply flex-1 min-h-0 items-stretch overflow-hidden;
|
||||
}
|
||||
.identity-workspace {
|
||||
@apply grid-cols-[clamp(240px,_22%,_280px)_minmax(0,_1fr)];
|
||||
}
|
||||
.identity-workspace > div {
|
||||
@apply min-h-0 overflow-hidden overscroll-contain;
|
||||
}
|
||||
.identity-subject-list {
|
||||
@apply max-h-none min-h-0 flex-1;
|
||||
}
|
||||
.directory-filters {
|
||||
@apply grid gap-2.5 pt-0 px-3 pb-3 shrink-0;
|
||||
}
|
||||
.directory-filters > * {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.identity-subject-list-content {
|
||||
@apply gap-0.5 pt-0 px-0 pb-2;
|
||||
}
|
||||
.n-button.identity-subject-item {
|
||||
@apply min-h-[86px] py-2.5 px-3.5;
|
||||
}
|
||||
.n-button.identity-subject-item:not(.selected) {
|
||||
@apply bg-(--app-surface);
|
||||
}
|
||||
.n-button.identity-subject-item:not(.selected):hover {
|
||||
@apply bg-(--app-subtle);
|
||||
}
|
||||
.identity-subject-item .directory-item-body {
|
||||
@apply gap-[3px];
|
||||
}
|
||||
.identity-subject-item .directory-item-title {
|
||||
@apply order-0 text-sm font-semibold;
|
||||
}
|
||||
.identity-subject-item .directory-item-eyebrow {
|
||||
@apply order-1 text-[10px];
|
||||
}
|
||||
.identity-subject-item .directory-item-meta {
|
||||
@apply order-2 gap-y-1 gap-x-1.5 text-[10px];
|
||||
}
|
||||
.casting-list {
|
||||
@apply grid grid-cols-[repeat(auto-fit,_minmax(min(100%,_260px),_1fr))] gap-3 mt-6 pt-5;
|
||||
}
|
||||
.n-button.casting-item {
|
||||
@apply w-full min-w-0 h-auto min-h-[76px] py-3 px-3.5 whitespace-normal text-left bg-(--app-subtle);
|
||||
}
|
||||
.n-button.casting-item.selected {
|
||||
@apply bg-(--app-selected) shadow-[inset_3px_0_0_var(--app-accent)];
|
||||
}
|
||||
.n-button.casting-item .n-button__content {
|
||||
@apply flex w-full min-w-0 items-center justify-between gap-4 text-left;
|
||||
}
|
||||
.casting-item-identity {
|
||||
@apply flex flex-col gap-[5px] min-w-0;
|
||||
}
|
||||
.casting-item-name {
|
||||
@apply text-sm font-medium leading-normal wrap-anywhere;
|
||||
}
|
||||
.casting-item-ref {
|
||||
@apply text-muted font-mono text-[11px] leading-normal wrap-anywhere;
|
||||
}
|
||||
.casting-item-status {
|
||||
@apply shrink-0;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.identity-workspace {
|
||||
@apply grid-cols-[minmax(0,_1fr)] grid-rows-[250px_minmax(0,_1fr)] gap-2.5;
|
||||
}
|
||||
.directory-filters {
|
||||
@apply grid-cols-[minmax(0,_1fr)_120px];
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -105,3 +105,18 @@ watch(
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../../styles/styles.css";
|
||||
.identity-thumbnail {
|
||||
@apply flex w-12 h-[64px] overflow-hidden bg-(--app-subtle);
|
||||
}
|
||||
.identity-thumbnail .n-image,
|
||||
.identity-thumbnail .n-image img {
|
||||
@apply w-full h-full;
|
||||
}
|
||||
.identity-thumbnail-placeholder {
|
||||
@apply flex flex-col items-center justify-center w-full h-full gap-[5px] text-muted text-[10px];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NImage, NScrollbar } from 'naive-ui'
|
||||
import { AssetImage } from '../../components/ui'
|
||||
import IdentityImageDialog from './components/IdentityImageDialog.vue'
|
||||
import IdentityGallery from './components/IdentityGallery.vue'
|
||||
import CastingCandidateDialog from './components/CastingCandidateDialog.vue'
|
||||
import { subjectIdentityApi } from './api'
|
||||
import {
|
||||
canBeAnchor,
|
||||
castingStatusLabel,
|
||||
currentAnchor,
|
||||
groupIdentitySubjects,
|
||||
mergeCastingSubjects,
|
||||
readImageProvenance
|
||||
} from './model'
|
||||
import { identityImageFixture } from './testing/fixtures'
|
||||
import { formFixture } from '../subject-images/testing/fixtures'
|
||||
import { expandSections, selectControl } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 从真实 Naive 弹窗内获取确认按钮。 */
|
||||
function button(label: string) {
|
||||
const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label)
|
||||
if (!item) throw new Error(`缺少按钮 ${label}`)
|
||||
return item
|
||||
}
|
||||
|
||||
/** 修改 Portal 表单控件并触发 Vue 绑定。 */
|
||||
function input(selector: string, value: string) {
|
||||
if (selector === '#identity-view' || selector === '#identity-reference') {
|
||||
selectControl(wrapper!, 'id', selector.slice(1)).vm.$emit('update:value', value)
|
||||
return
|
||||
}
|
||||
const item = document.querySelector<HTMLInputElement | HTMLSelectElement>(selector)!
|
||||
item.value = value
|
||||
item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('身份图与母版契约', () => {
|
||||
it('详情大图 contain 完整显示,历史仍为 cover,缩略图只切换记录', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: true,
|
||||
disabled: false,
|
||||
images: [
|
||||
identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png' }),
|
||||
identityImageFixture({
|
||||
id: 'front',
|
||||
viewType: 'front',
|
||||
isAnchor: false,
|
||||
imageUrl: '/storage/front.png'
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
const images = wrapper.findAllComponents(NImage)
|
||||
expect(images).toHaveLength(3)
|
||||
expect(images.map(image => image.props('objectFit'))).toEqual(['contain', 'cover', 'cover'])
|
||||
expect(images.map(image => image.props('previewDisabled'))).toEqual([false, true, true])
|
||||
await wrapper.get('[aria-label="查看身份图片 front"] img').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
const main = wrapper.get('.asset-image-preview img')
|
||||
expect((main.element as HTMLImageElement).style.objectFit).toBe('contain')
|
||||
expect(main.attributes('src')).toContain('/storage/front.png')
|
||||
await main.trigger('click')
|
||||
await flushPromises()
|
||||
const original = document.querySelector<HTMLImageElement>('.n-image-preview')!
|
||||
expect(original.getAttribute('src')).toBe(main.attributes('src'))
|
||||
expect(original.style.objectFit).not.toBe('cover')
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('实际提示词默认展开,可手动收起,轮询更新不强行重新展开', async () => {
|
||||
const image = identityImageFixture({ prompt: '本次实际生成提示词' })
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [image] }
|
||||
})
|
||||
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('更新后的实际提示词')
|
||||
})
|
||||
|
||||
it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => {
|
||||
const images = [
|
||||
identityImageFixture({
|
||||
id: 'candidate',
|
||||
imageUrl: '/storage/candidate.png',
|
||||
isAnchor: false,
|
||||
enabled: 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: 'generating',
|
||||
status: 'generating',
|
||||
imageUrl: null,
|
||||
isAnchor: false,
|
||||
enabled: false
|
||||
})
|
||||
]
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images }
|
||||
})
|
||||
const history = wrapper.get('[aria-label="身份图片历史"]')
|
||||
expect(history.findAll('.image-history-item')).toHaveLength(6)
|
||||
expect(wrapper.getComponent(NScrollbar).props()).toMatchObject({
|
||||
xScrollable: true,
|
||||
trigger: 'none',
|
||||
contentStyle: { width: 'max-content' }
|
||||
})
|
||||
expect(wrapper.get('.asset-image-preview').element.nextElementSibling?.textContent).toContain('历史记录 · 6 条')
|
||||
expect(wrapper.get('.image-history-heading').element.nextElementSibling).toBe(history.element)
|
||||
expect(history.text()).toContain('当前母版')
|
||||
expect(history.text()).toContain('母版候选')
|
||||
expect(history.text()).toContain('正面')
|
||||
expect(history.text()).toContain('生成失败')
|
||||
expect(history.text()).toContain('排队中')
|
||||
expect(history.text()).toContain('生成中')
|
||||
expect(history.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
|
||||
for (const image of images) {
|
||||
await history.get(`[aria-label="查看身份图片 ${image.id}"]`).trigger('click')
|
||||
expect(history.findAll('[aria-pressed="true"]')).toHaveLength(1)
|
||||
expect(history.get(`[aria-label="查看身份图片 ${image.id}"]`).attributes('aria-pressed')).toBe('true')
|
||||
const preview = wrapper
|
||||
.findAllComponents(AssetImage)
|
||||
.find(item => item.classes().includes('asset-image-preview'))!
|
||||
expect(preview.props('src')).toBe(image.status === 'completed' ? image.imageUrl : null)
|
||||
expect(wrapper.text()).toContain(`Identity image ID · ${image.id}`)
|
||||
}
|
||||
await history.get('[aria-label="查看身份图片 failed"]').trigger('click')
|
||||
expect(wrapper.get('.asset-image-preview').text()).toContain('本次生成失败')
|
||||
expect(wrapper.findAll('[role="alert"]').map(item => item.text())).toContain('图片模型超时')
|
||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => {
|
||||
const anchor = identityImageFixture({ id: 'anchor' })
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: true,
|
||||
disabled: false,
|
||||
images: [anchor, front]
|
||||
}
|
||||
})
|
||||
await wrapper.get('[aria-label="查看身份图片 front"]').trigger('click')
|
||||
const viewport = wrapper.get<HTMLElement>('.image-history .n-scrollbar-container').element
|
||||
viewport.scrollLeft = 120
|
||||
await wrapper.setProps({ images: [identityImageFixture({ id: 'new', isAnchor: false }), anchor, front] })
|
||||
expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('.image-history .n-scrollbar-container').element).toBe(viewport)
|
||||
expect(viewport.scrollLeft).toBe(120)
|
||||
await wrapper.setProps({ images: [anchor] })
|
||||
expect(wrapper.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.get('.image-history-heading').text()).toContain('历史记录 · 1 条')
|
||||
})
|
||||
|
||||
it('切换历史取消上一张的确认,只有再次确认才能发出当前候选 ID', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: ['candidate-a', 'candidate-b'].map(id =>
|
||||
identityImageFixture({ id, isAnchor: false, enabled: false })
|
||||
)
|
||||
}
|
||||
})
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
expect(button('确认演员选择').disabled).toBe(false)
|
||||
await wrapper.get('[aria-label="查看身份图片 candidate-b"]').trigger('click')
|
||||
expect(wrapper.text()).not.toContain('确认选择这张图片作为正式演员')
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
button('确认演员选择').click()
|
||||
expect(wrapper.emitted('anchor')).toEqual([['candidate-b']])
|
||||
})
|
||||
|
||||
it('无历史图片时只展示空状态,不伪造缩略图或母版', () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [] }
|
||||
})
|
||||
expect(wrapper.text()).toContain('身份参考图 · 0')
|
||||
expect(wrapper.text()).toContain('尚无身份参考图')
|
||||
expect(wrapper.find('.image-history').exists()).toBe(false)
|
||||
expect(wrapper.find('.asset-image-preview').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => {
|
||||
const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false })
|
||||
const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false })
|
||||
expect(canBeAnchor(front)).toBe(false)
|
||||
expect(canBeAnchor(candidate)).toBe(true)
|
||||
expect(currentAnchor([front, candidate])).toBeUndefined()
|
||||
expect(canBeAnchor(identityImageFixture({ status: 'failed' }))).toBe(false)
|
||||
expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false)
|
||||
})
|
||||
|
||||
it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => {
|
||||
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('选角就绪结果可以补入尚无形态的角色目录', () => {
|
||||
const rows = mergeCastingSubjects(
|
||||
groupIdentitySubjects([formFixture()]),
|
||||
[
|
||||
{
|
||||
subjectId: 'character-without-form',
|
||||
subjectRef: '@CH0002',
|
||||
subjectName: '陆川',
|
||||
status: 'missing_identity',
|
||||
isLocked: false
|
||||
}
|
||||
],
|
||||
'project-1'
|
||||
)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.find(item => item.id === 'character-without-form')).toMatchObject({
|
||||
projectId: 'project-1',
|
||||
module: 'character',
|
||||
forms: []
|
||||
})
|
||||
expect(castingStatusLabel('candidate_pending')).toBe('等待确认演员')
|
||||
})
|
||||
|
||||
it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
input('#identity-view', 'three-quarter')
|
||||
input('#identity-reference', 'identity-image-1')
|
||||
input('#identity-width', '2048')
|
||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
||||
await flushPromises()
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
input('#identity-height', '2048')
|
||||
input('#identity-image-prompt', ' 自定义身份提示词 ')
|
||||
await flushPromises()
|
||||
button('确认生成身份图').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([
|
||||
[
|
||||
{
|
||||
viewType: 'three-quarter',
|
||||
referenceImageId: 'identity-image-1',
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
prompt: '自定义身份提示词'
|
||||
}
|
||||
]
|
||||
])
|
||||
})
|
||||
|
||||
it('切换主体清空生图配置和费用确认,失效参考图不能提交', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
input('#identity-reference', 'identity-image-1')
|
||||
document.querySelector<HTMLInputElement>('#identity-image-cost')!.click()
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ images: [] })
|
||||
expect(button('确认生成身份图').disabled).toBe(true)
|
||||
await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' })
|
||||
expect(selectControl(wrapper!, 'id', 'identity-reference').props('value')).toBe('')
|
||||
expect(document.querySelector('#identity-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||
})
|
||||
|
||||
it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => {
|
||||
wrapper = mount(IdentityImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
open: true,
|
||||
subjectId: 's1',
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
images: [identityImageFixture()],
|
||||
disabled: false
|
||||
}
|
||||
})
|
||||
await flushPromises()
|
||||
const select = selectControl(wrapper!, 'id', 'identity-view')
|
||||
expect(select.props('options')!.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body'])
|
||||
expect(select.props('value')).toBe('front')
|
||||
})
|
||||
|
||||
it('辅助图不能切换母版,提示词按纯文本展示', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: [identityImageFixture({ viewType: 'front', isAnchor: false })]
|
||||
}
|
||||
})
|
||||
expect(button('确认选角并锁定').disabled).toBe(true)
|
||||
await expandSections()
|
||||
expect(wrapper.text()).toContain('<script>不执行</script>')
|
||||
expect(wrapper.find('script').exists()).toBe(false)
|
||||
expect(wrapper.emitted('anchor')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('角色已有母版但未锁定时仍可确认选角,确认文案不冒充普通母版切换', async () => {
|
||||
wrapper = mount(IdentityGallery, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
subjectName: '林夏',
|
||||
module: 'character',
|
||||
identityLocked: false,
|
||||
disabled: false,
|
||||
images: [identityImageFixture({ isAnchor: true })]
|
||||
}
|
||||
})
|
||||
button('确认选角并锁定').click()
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('同一事务中切换身份母版并锁定 Identity')
|
||||
button('确认演员选择').click()
|
||||
expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']])
|
||||
})
|
||||
|
||||
it('选角候选不再发送 Provider,不携带普通身份图视角字段', async () => {
|
||||
wrapper = mount(CastingCandidateDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('不会复用当前身份母版')
|
||||
document.querySelector<HTMLInputElement>('#casting-candidate-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成候选').click()
|
||||
expect(wrapper.emitted('generate')).toEqual([[{}]])
|
||||
})
|
||||
|
||||
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
total: 0,
|
||||
targetCount: 0,
|
||||
generated: 0,
|
||||
skipped: 0,
|
||||
skippedLocked: 0,
|
||||
failed: 0,
|
||||
failures: []
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 })
|
||||
await subjectIdentityApi.generateCastingCandidate('subject/1', {})
|
||||
await subjectIdentityApi.confirmCasting('subject/1', 'image/1')
|
||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/projects/project%2F1/character-identities/generate',
|
||||
'/api/subjects/subject%2F1/identity/casting-candidates',
|
||||
'/api/subjects/subject%2F1/identity/images/image%2F1/casting'
|
||||
])
|
||||
expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(['POST', 'POST', 'PUT'])
|
||||
})
|
||||
})
|
||||
@@ -1,182 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NImage } from 'naive-ui'
|
||||
import IdentityThumbnail from './components/IdentityThumbnail.vue'
|
||||
import { identityFixture, identityImageFixture } from './testing/fixtures'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
let observers: {
|
||||
callback: IntersectionObserverCallback
|
||||
observe: ReturnType<typeof vi.fn<(element: Element) => void>>
|
||||
disconnect: ReturnType<typeof vi.fn<() => void>>
|
||||
}[] = []
|
||||
const props = {
|
||||
projectId: 'project',
|
||||
subjectId: 'subject-db-1',
|
||||
name: '书店',
|
||||
module: 'scene',
|
||||
identityId: 'identity-db-1',
|
||||
refreshKey: 0
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
observers = []
|
||||
vi.stubGlobal(
|
||||
'IntersectionObserver',
|
||||
class {
|
||||
observe = vi.fn<(element: Element) => void>()
|
||||
disconnect = vi.fn<() => void>()
|
||||
unobserve = vi.fn<(element: Element) => void>()
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
observers.push({ callback, observe: this.observe, disconnect: this.disconnect })
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 仅触发主体缩略图的可见事件,不触发 NImage 内部图片懒加载观察器。 */
|
||||
async function reveal() {
|
||||
const observer = observers.find(item => item.observe.mock.calls.some(([element]) => element === wrapper!.element))!
|
||||
observer.callback([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver)
|
||||
await flushPromises()
|
||||
expect(observer.disconnect).toHaveBeenCalled()
|
||||
}
|
||||
|
||||
/** 测试接口返回独立 Response,读取图片元数据不消耗生成额度。 */
|
||||
function response(data: unknown) {
|
||||
return new Response(JSON.stringify({ data }))
|
||||
}
|
||||
|
||||
describe('主体目录母版缩略图', () => {
|
||||
it.each([
|
||||
['character', '人物'],
|
||||
['scene', '场景'],
|
||||
['prop', '道具']
|
||||
])('没有母版的 %s 显示类型占位,不补查或生成图片', async (module, label) => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props: { ...props, module, source: null } })
|
||||
await reveal()
|
||||
expect(wrapper.text()).toBe(label)
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
|
||||
expect(wrapper.findComponent(NImage).exists()).toBe(false)
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('直接复用目录母版地址,图片不抢占选择点击,加载失败显示默认占位', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>()
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, {
|
||||
attachTo: document.body,
|
||||
props: { ...props, source: '/storage/anchor.png' }
|
||||
})
|
||||
await reveal()
|
||||
expect(wrapper.getComponent(NImage).props()).toMatchObject({ objectFit: 'cover', previewDisabled: true })
|
||||
await wrapper.get('img').trigger('click')
|
||||
expect(document.querySelector('.n-image-preview-container')).toBeNull()
|
||||
await wrapper.get('img').trigger('error')
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
||||
await wrapper.setProps({ source: '/storage/new-anchor.png' })
|
||||
expect(wrapper.get('img').attributes('src')).toContain('/storage/new-anchor.png')
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('进入可视区域后才 GET 图库,仅选择权威母版,刷新时重新读取', async () => {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async () =>
|
||||
response([
|
||||
identityImageFixture({
|
||||
id: 'candidate',
|
||||
isAnchor: false,
|
||||
enabled: false,
|
||||
imageUrl: '/storage/candidate.png'
|
||||
}),
|
||||
identityImageFixture({
|
||||
id: 'front',
|
||||
isAnchor: false,
|
||||
viewType: 'front',
|
||||
imageUrl: '/storage/front.png'
|
||||
}),
|
||||
identityImageFixture({ imageUrl: '/storage/anchor.png' })
|
||||
])
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props })
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await reveal()
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/subjects/subject-db-1/identity/images')
|
||||
expect(wrapper.get('img').attributes('src')).toContain('/storage/anchor.png')
|
||||
await wrapper.setProps({ refreshKey: 1 })
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
it('目录未附带身份摘要时先校验正式身份,再读取母版', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async url =>
|
||||
response(String(url).endsWith('/images') ? [identityImageFixture()] : identityFixture())
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props: { ...props, identityId: undefined } })
|
||||
await reveal()
|
||||
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
||||
'/api/subjects/subject-db-1/identity',
|
||||
'/api/subjects/subject-db-1/identity/images'
|
||||
])
|
||||
expect(wrapper.find('img').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('切项目中止旧请求,即使迟到也不能覆盖新主体占位', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props })
|
||||
await reveal()
|
||||
await wrapper.setProps({
|
||||
projectId: 'other-project',
|
||||
subjectId: 'other-subject',
|
||||
name: '另一主体',
|
||||
source: null
|
||||
})
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
|
||||
finish(response([identityImageFixture()]))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toBe('另一主体 · 暂无母版')
|
||||
})
|
||||
|
||||
it('候选与辅助视角不能冒充母版,跨身份图片返回错误占位', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(response([identityImageFixture({ isAnchor: false })]))
|
||||
.mockResolvedValueOnce(response([identityImageFixture({ identityId: 'wrong-identity' })]))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(IdentityThumbnail, { props })
|
||||
await reveal()
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('暂无母版')
|
||||
await wrapper.setProps({ refreshKey: 1 })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('危险地址不写入图片,无效图片也不会触发生图', () => {
|
||||
wrapper = mount(IdentityThumbnail, { props: { ...props, source: 'javascript:alert(1)' } })
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
expect(wrapper.get('[role="img"]').attributes('aria-label')).toContain('母版暂不可用')
|
||||
})
|
||||
})
|
||||
@@ -739,3 +739,118 @@ async function showStaleForms() {
|
||||
@changed="refreshAssets" /></template
|
||||
></WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.form-image-grid {
|
||||
@apply grid grid-cols-[repeat(auto-fill,_minmax(min(260px,_100%),_1fr))] gap-5;
|
||||
}
|
||||
.form-image-card {
|
||||
@apply overflow-hidden;
|
||||
}
|
||||
.gallery-workspace-page .workspace-scroll-content {
|
||||
@apply pt-2;
|
||||
}
|
||||
.gallery-workspace-page .gallery-sticky-controls {
|
||||
@apply sticky top-0 z-10 bg-(--app-body) mt-2.5 mb-0;
|
||||
}
|
||||
.gallery-workspace-page .form-image-grid {
|
||||
@apply pt-3;
|
||||
}
|
||||
.gallery-workspace-page .form-image-masonry {
|
||||
@apply block columns-[260px] gap-x-5;
|
||||
}
|
||||
.form-image-masonry > .form-image-card {
|
||||
@apply break-inside-avoid mb-5;
|
||||
}
|
||||
.form-image-masonry .asset-image {
|
||||
@apply aspect-[var(--form-image-aspect,4/3)];
|
||||
}
|
||||
.gallery-sticky-controls > .form-image-filter-region {
|
||||
@apply my-0;
|
||||
}
|
||||
.gallery-workspace-page .workspace-scroll > .n-scrollbar-container {
|
||||
@apply [overflow-anchor:none];
|
||||
}
|
||||
.form-image-filter-region {
|
||||
@apply my-4 py-3.5 px-4 bg-(--app-subtle);
|
||||
container-type: inline-size;
|
||||
}
|
||||
.form-image-toolbar {
|
||||
@apply grid grid-cols-[minmax(0,_1fr)] items-center gap-y-3 gap-x-4;
|
||||
}
|
||||
.form-image-toolbar.has-impact-picker {
|
||||
@apply grid-cols-[minmax(220px,_340px)_minmax(0,_1fr)];
|
||||
}
|
||||
.form-image-toolbar.has-impact-picker.has-impact-actions {
|
||||
@apply grid-cols-[minmax(260px,_420px)_minmax(0,_1fr)];
|
||||
}
|
||||
.asset-impact-picker {
|
||||
@apply flex items-center gap-2 min-w-0;
|
||||
}
|
||||
.asset-impact-picker .asset-impact-select {
|
||||
@apply flex-1;
|
||||
}
|
||||
.form-image-toolbar > * {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.form-image-filters {
|
||||
@apply grid grid-cols-[minmax(200px,_360px)_152px_minmax(260px,_1fr)_auto] items-center gap-y-3 gap-x-4;
|
||||
}
|
||||
.form-image-filters > * {
|
||||
@apply min-w-0;
|
||||
}
|
||||
.filter-toggles {
|
||||
@apply flex items-center flex-wrap gap-y-2.5 gap-x-4 text-xs;
|
||||
}
|
||||
.filter-toggles .n-checkbox__label {
|
||||
@apply whitespace-nowrap;
|
||||
}
|
||||
.filter-result-count {
|
||||
@apply justify-self-end text-muted text-xs whitespace-nowrap;
|
||||
}
|
||||
.form-image-display-controls {
|
||||
@apply flex items-center justify-self-end gap-3;
|
||||
}
|
||||
.gallery-layout-switch {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
@container (max-width: 1200px) {
|
||||
.form-image-toolbar.has-impact-picker,
|
||||
.form-image-toolbar.has-impact-picker.has-impact-actions {
|
||||
@apply grid-cols-[minmax(0,_1fr)];
|
||||
}
|
||||
}
|
||||
@container (max-width: 900px) {
|
||||
.form-image-filters {
|
||||
@apply grid-cols-[minmax(0,_1fr)_152px];
|
||||
}
|
||||
.form-image-display-controls {
|
||||
@apply col-[1/-1];
|
||||
}
|
||||
}
|
||||
@container (max-width: 460px) {
|
||||
.form-image-filters {
|
||||
@apply grid-cols-[minmax(0,_1fr)_120px] gap-x-2.5;
|
||||
}
|
||||
}
|
||||
.asset-impact-context {
|
||||
@apply flex flex-col gap-2.5 py-[5px] px-4 bg-(--app-subtle) m-0 min-w-0;
|
||||
}
|
||||
.asset-impact-links-scroll.n-scrollbar {
|
||||
@apply h-auto min-w-0 max-w-full;
|
||||
}
|
||||
.asset-impact-links {
|
||||
@apply flex items-center gap-3 w-max whitespace-nowrap min-h-8 py-1.5;
|
||||
}
|
||||
.asset-impact-links > * {
|
||||
@apply shrink-0;
|
||||
}
|
||||
.asset-impact-select {
|
||||
@apply min-w-0 w-full;
|
||||
}
|
||||
.form-image-card-focused {
|
||||
@apply shadow-[inset_3px_0_0_var(--app-accent-text)];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,504 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NSelect } from 'naive-ui'
|
||||
import { testProjectContext } from '../../testing/project-context'
|
||||
import { projectContextKey } from '../projects/context'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
import { referenceLocation, referenceTargets } from '../production/asset-links'
|
||||
import StaleKeyframeNotice from '../production/components/StaleKeyframeNotice.vue'
|
||||
import SubjectImagesPage from './SubjectImagesPage.vue'
|
||||
import ProductionPage from '../production/ProductionPage.vue'
|
||||
import { directionsResult, storyboardCheckpoint } from '../storyboard/testing/fixtures'
|
||||
import type { ProductionIssue } from '../production/types'
|
||||
import type { ShotReferences } from '../storyboard/types'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 镜头引用非默认形态,用于防止退化成仅按主体名搜索。 */
|
||||
function fixture() {
|
||||
const first = formFixture('capability-test')
|
||||
const second = {
|
||||
...first,
|
||||
id: 'form-special',
|
||||
name: '雨夜形态',
|
||||
isDefault: false,
|
||||
images: [imageFixture({ subjectFormId: 'form-special' })]
|
||||
}
|
||||
const other = {
|
||||
...formFixture('capability-test'),
|
||||
id: 'form-other',
|
||||
subjectId: 'subject-other',
|
||||
subject: { ...first.subject, id: 'subject-other', name: '路人', ref: '@CH0005' },
|
||||
images: [imageFixture({ subjectFormId: 'form-other' })]
|
||||
}
|
||||
const references: ShotReferences = {
|
||||
shotId: 'shot-target',
|
||||
references: [
|
||||
{
|
||||
shotSubjectId: 'binding-1',
|
||||
subjectId: first.subjectId,
|
||||
subjectRef: first.subject.ref,
|
||||
subjectName: first.subject.name,
|
||||
module: 'character',
|
||||
subjectFormId: second.id,
|
||||
subjectFormName: second.name,
|
||||
imageId: 'image-db-1',
|
||||
imageUrl: '/storage/current.png'
|
||||
},
|
||||
{
|
||||
shotSubjectId: 'binding-2',
|
||||
subjectId: other.subjectId,
|
||||
subjectRef: other.subject.ref,
|
||||
subjectName: other.subject.name,
|
||||
module: 'character',
|
||||
subjectFormId: other.id,
|
||||
subjectFormName: other.name,
|
||||
imageId: 'other-image',
|
||||
imageUrl: '/storage/other.png'
|
||||
}
|
||||
],
|
||||
missing: []
|
||||
}
|
||||
const issues: ProductionIssue[] = [
|
||||
{ code: 'stale_keyframe', reason: '参考资产已变化: @CH0001, @CH0005', missingSubjects: ['@CH0001', '@CH0005'] }
|
||||
]
|
||||
const keyframes = {
|
||||
total: 1,
|
||||
ready: 1,
|
||||
skipped: 0,
|
||||
blocked: 0,
|
||||
stalePrimaryKeyframe: 1,
|
||||
items: [
|
||||
{
|
||||
shotId: 'shot-target',
|
||||
shotNo: 1,
|
||||
episodeNo: 2,
|
||||
beatNo: 2,
|
||||
status: 'ready',
|
||||
primaryKeyframeStale: true,
|
||||
primaryKeyframeId: 'keyframe-old',
|
||||
issues: []
|
||||
}
|
||||
]
|
||||
}
|
||||
const videos = {
|
||||
total: 1,
|
||||
ready: 0,
|
||||
skipped: 0,
|
||||
blocked: 1,
|
||||
items: [{ shotId: 'shot-target', shotNo: 1, status: 'blocked', issues }]
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
const path = String(url)
|
||||
const data = path.endsWith('/subject-forms')
|
||||
? [first, second, other]
|
||||
: path.includes('/keyframes/readiness')
|
||||
? keyframes
|
||||
: path.includes('/videos/readiness')
|
||||
? videos
|
||||
: path.endsWith('/references')
|
||||
? references
|
||||
: []
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
return { first, second, other, references, issues, keyframes, videos, fetcher }
|
||||
}
|
||||
|
||||
/** 在真实内存路由中验证跨页 URL,不替换 RouterLink 行为。 */
|
||||
async function gallery(query = '') {
|
||||
const data = fixture()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/projects/:projectId/subject-images', component: SubjectImagesPage },
|
||||
{ path: '/projects/:projectId/production', component: { template: '<div />' } }
|
||||
]
|
||||
})
|
||||
await router.push('/projects/capability-test/subject-images' + query)
|
||||
wrapper = mount(SubjectImagesPage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: testProjectContext() } }
|
||||
})
|
||||
await flushPromises()
|
||||
return { ...data, router }
|
||||
}
|
||||
|
||||
describe('过期首帧到具体素材定位', () => {
|
||||
it('网格与瀑布流切换保留卡片、滚动容器和筛选,不发起额外请求', async () => {
|
||||
const { fetcher } = await gallery()
|
||||
const requestCount = fetcher.mock.calls.length
|
||||
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
|
||||
const grid = wrapper!.get('.form-image-grid').element
|
||||
const cards = wrapper!.findAll('.form-image-card').map(card => card.element)
|
||||
scroll.scrollTop = 240
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true')
|
||||
await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click')
|
||||
expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
|
||||
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false')
|
||||
expect(wrapper!.findAll('.form-image-card').map(card => card.element)).toEqual(cards)
|
||||
expect(scroll.scrollTop).toBe(240)
|
||||
expect(
|
||||
wrapper!.get<HTMLElement>('.form-image-card').element.style.getPropertyValue('--form-image-aspect')
|
||||
).toBe('2048 / 2048')
|
||||
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜')
|
||||
await wrapper!.get('[aria-label="网格布局"]').trigger('click')
|
||||
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
|
||||
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
||||
expect(wrapper!.get('.form-image-card').text()).toContain('雨夜形态')
|
||||
expect(wrapper!.get('.form-image-card').text()).toContain('查看图片与记录')
|
||||
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
|
||||
expect(fetcher).toHaveBeenCalledTimes(requestCount)
|
||||
})
|
||||
|
||||
it('图库与关联检查不定时刷新,手动刷新仍能更新数据', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { fetcher } = await gallery('?sourceShotId=shot-target')
|
||||
const count = fetcher.mock.calls.length
|
||||
const grid = wrapper!.get('.form-image-grid').element
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(count)
|
||||
expect(wrapper!.get('.form-image-grid').element).toBe(grid)
|
||||
const refresh = wrapper!.findAll('button').find(button => button.text().includes('刷新图库'))!
|
||||
await refresh.trigger('click')
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.length).toBeGreaterThan(count)
|
||||
const refreshed = fetcher.mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetcher).toHaveBeenCalledTimes(refreshed)
|
||||
})
|
||||
|
||||
it('切换镜头时筛选、关联说明和图片共用稳定纵向容器,长关联列表单独横向滚动', async () => {
|
||||
const { keyframes, videos, references, fetcher } = await gallery('?sourceShotId=shot-target')
|
||||
expect(wrapper!.find('.workspace-heading-scroll').exists()).toBe(false)
|
||||
const scroll = wrapper!.get<HTMLElement>('.workspace-scroll > .n-scrollbar-container').element
|
||||
const content = wrapper!.get('.workspace-scroll-content').element
|
||||
const sticky = wrapper!.get('.gallery-sticky-controls').element
|
||||
const toolbar = wrapper!.get('.form-image-filter-region').element
|
||||
expect(sticky.parentElement).toBe(content)
|
||||
expect(toolbar.parentElement).toBe(sticky)
|
||||
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(sticky)
|
||||
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
|
||||
scroll.scrollTop = 480
|
||||
keyframes.items.push({ ...keyframes.items[0]!, shotId: 'shot-next', shotNo: 2 })
|
||||
videos.items.push({ ...videos.items[0]!, shotId: 'shot-next', shotNo: 2 })
|
||||
keyframes.total = videos.total = 2
|
||||
let finish!: (response: Response) => void
|
||||
const original = fetcher.getMockImplementation()!
|
||||
fetcher.mockImplementation((url, init) =>
|
||||
String(url).endsWith('/shot-next/references')
|
||||
? new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
: original(url, init)
|
||||
)
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '刷新图库')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
wrapper!
|
||||
.findAllComponents(NSelect)
|
||||
.find(item => item.attributes('aria-label') === '选择关联镜头')!
|
||||
.vm.$emit('update:value', 'shot-next')
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('正在读取镜头关联素材')
|
||||
finish(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: {
|
||||
...references,
|
||||
shotId: 'shot-next',
|
||||
references: Array.from({ length: 24 }, (_, index) => ({
|
||||
...references.references[0]!,
|
||||
subjectFormId: `long-form-${index}`,
|
||||
subjectFormName: `很长的关联素材名称${index}`
|
||||
}))
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.workspace-scroll > .n-scrollbar-container').element).toBe(scroll)
|
||||
expect(wrapper!.get('.form-image-filter-region').element).toBe(toolbar)
|
||||
expect(scroll.scrollTop).toBe(480)
|
||||
expect(wrapper!.get('.gallery-sticky-controls').element).toBe(sticky)
|
||||
expect(wrapper!.get('.asset-impact-context').element.parentElement).toBe(sticky)
|
||||
expect(wrapper!.get('.form-image-grid').element.parentElement).toBe(content)
|
||||
const links = wrapper!.get('.asset-impact-links-scroll')
|
||||
expect(links.findAll('a')).toHaveLength(24)
|
||||
expect(links.text()).toContain('很长的关联素材名称23')
|
||||
expect(links.classes()).toContain('n-scrollbar')
|
||||
expect(links.find('.n-scrollbar-container > .asset-impact-links').exists()).toBe(true)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
it('切换镜头取消旧参考图查询,迟到响应不会产生旧素材链接', async () => {
|
||||
const { references } = fixture()
|
||||
let finish!: (response: Response) => void
|
||||
const nextReferences = {
|
||||
...references,
|
||||
shotId: 'shot-new',
|
||||
references: [{ ...references.references[0]!, subjectFormId: 'form-new', subjectFormName: '新镜头形态' }]
|
||||
}
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(url =>
|
||||
String(url).includes('shot-target')
|
||||
? new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
: Promise.resolve(new Response(JSON.stringify({ data: nextReferences })))
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
await router.push('/')
|
||||
wrapper = mount(StaleKeyframeNotice, {
|
||||
global: { plugins: [router] },
|
||||
props: { projectId: 'capability-test', shotId: 'shot-target', issues: [] }
|
||||
})
|
||||
await flushPromises()
|
||||
const signal = fetcher.mock.calls[0]?.[1]?.signal
|
||||
await wrapper.setProps({ shotId: 'shot-new' })
|
||||
await flushPromises()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
finish(new Response(JSON.stringify({ data: references })))
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('新镜头形态')
|
||||
expect(wrapper.findAll('a').some(link => link.attributes('href')?.includes('form-special'))).toBe(false)
|
||||
})
|
||||
it('参考素材查询失败时保留引用级定位和重试,不静默选择默认形态', async () => {
|
||||
const { issues } = fixture()
|
||||
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockResolvedValue(new Response('{}', { status: 500 })))
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
await router.push('/')
|
||||
wrapper = mount(StaleKeyframeNotice, {
|
||||
global: { plugins: [router] },
|
||||
props: { projectId: 'capability-test', shotId: 'shot-target', issues, inconsistent: true }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('状态待核对')
|
||||
expect(wrapper.text()).toContain('重试定位')
|
||||
expect(wrapper.findAll('a').every(link => !link.attributes('href')?.includes('subjectFormId'))).toBe(true)
|
||||
})
|
||||
it('结构化变更引用映射到实际使用的非默认形态,并保留来源镜头', () => {
|
||||
const { references, issues } = fixture()
|
||||
const targets = referenceTargets(references, issues)
|
||||
expect(targets.map(item => item.formId)).toEqual(['form-special', 'form-other'])
|
||||
expect(referenceLocation('project/1', 'shot-target', targets[0]!)).toEqual({
|
||||
path: '/projects/project%2F1/subject-images',
|
||||
query: { sourceShotId: 'shot-target', subjectRef: '@CH0001', subjectFormId: 'form-special' }
|
||||
})
|
||||
})
|
||||
it('没有当前关联时只回退到引用,不猜默认形态;没有变更列表时展示关联素材', () => {
|
||||
const { references } = fixture()
|
||||
const targets = referenceTargets(references, [
|
||||
{ code: 'stale_keyframe', reason: '历史主体被移除', missingSubjects: ['@CH0999'] }
|
||||
])
|
||||
expect(targets[0]?.formId).toBeUndefined()
|
||||
expect(targets[0]?.label).toContain('当前形态待核对')
|
||||
expect(referenceTargets(references, [])).toHaveLength(2)
|
||||
})
|
||||
it('生产警告的引用可点击定位,点击不提交生图', async () => {
|
||||
const { issues, fetcher } = fixture()
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/:pathMatch(.*)*', component: { template: '<div />' } }]
|
||||
})
|
||||
await router.push('/projects/capability-test/production')
|
||||
wrapper = mount(StaleKeyframeNotice, {
|
||||
global: { plugins: [router] },
|
||||
props: { projectId: 'capability-test', shotId: 'shot-target', issues }
|
||||
})
|
||||
await flushPromises()
|
||||
const links = wrapper.findAll('a')
|
||||
expect(links).toHaveLength(2)
|
||||
expect(links[0]?.text()).toContain('雨夜形态')
|
||||
await links[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query.subjectFormId).toBe('form-special')
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
it('图库直接访问即显示下游警告,但不把有效素材误标成身份过期', async () => {
|
||||
const { fetcher } = await gallery()
|
||||
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('1 个镜头')
|
||||
expect(wrapper!.text()).toContain('不表示形态图片本身失效')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
expect(wrapper!.text()).not.toContain('个形态主图与当前已锁定身份母版不一致')
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).endsWith('/references'))).toBe(false)
|
||||
// 镜头选择和图库筛选共享上方工具栏;未选择镜头时不留下空的关联说明面板。
|
||||
const toolbar = wrapper!.get('.form-image-toolbar')
|
||||
const picker = toolbar.get('.asset-impact-picker').element
|
||||
expect(toolbar.classes()).toContain('has-impact-picker')
|
||||
expect(picker.nextElementSibling).toBe(toolbar.get('.form-image-filters').element)
|
||||
expect(toolbar.find('input[aria-label="搜索形态图片"]').exists()).toBe(true)
|
||||
expect(toolbar.findAll('.filter-toggles [role="checkbox"]')).toHaveLength(2)
|
||||
expect(wrapper!.find('.asset-impact-context').exists()).toBe(false)
|
||||
const select = wrapper!
|
||||
.findAllComponents(NSelect)
|
||||
.find(item => item.attributes('aria-label') === '选择关联镜头')!
|
||||
select.vm.$emit('update:value', 'shot-target')
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.asset-impact-context').element.previousElementSibling).toBe(
|
||||
wrapper!.get('.form-image-filter-region').element
|
||||
)
|
||||
expect(wrapper!.text()).toContain('待处理首帧关联此素材')
|
||||
expect(fetcher.mock.calls.filter(([url]) => String(url).endsWith('/references'))).toHaveLength(1)
|
||||
})
|
||||
it('镜头导航与筛选共行,无关联说明时不留横条,查看全部素材清除定位和筛选', async () => {
|
||||
const { references, router, fetcher } = await gallery()
|
||||
references.references = []
|
||||
await router.push({ query: { sourceShotId: 'shot-target' } })
|
||||
await flushPromises()
|
||||
const picker = wrapper!.get('.asset-impact-picker')
|
||||
const back = picker.get('a[aria-label="返回第 2 集 · 镜头 1"]')
|
||||
expect(back.classes()).toContain('icon-button')
|
||||
expect(new URL(back.attributes('href')!, 'https://local.test').searchParams.get('shotId')).toBe('shot-target')
|
||||
expect(picker.get('[aria-label="查看全部素材"]').classes()).toContain('icon-button')
|
||||
expect(wrapper!.find('.asset-impact-context').exists()).toBe(false)
|
||||
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('不存在的素材')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
|
||||
await picker.get('[aria-label="查看全部素材"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(router.currentRoute.value.query.sourceShotId).toBeUndefined()
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
expect(wrapper!.find('.asset-impact-picker a').exists()).toBe(false)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
it('精准定位卡片、清空旧类型筛选、提供可恢复原镜头的返回链接', async () => {
|
||||
const { router } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special&subjectRef=%40CH0001')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(1)
|
||||
expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-special')
|
||||
expect(wrapper!.get('.form-image-card').classes()).toContain('form-image-card-focused')
|
||||
expect(wrapper!.get('[data-focused-material]').text()).toContain('雨夜形态')
|
||||
const back = wrapper!.findAll('a').find(link => link.text().includes('返回第 2 集'))!
|
||||
const url = new URL(back.attributes('href')!, 'https://local.test')
|
||||
expect(url.searchParams.get('episodeNo')).toBe('2')
|
||||
expect(url.searchParams.get('shotId')).toBe('shot-target')
|
||||
const type = wrapper!.findAllComponents(NSelect).find(item => item.attributes('aria-label') === '筛选主体类型')!
|
||||
type.vm.$emit('update:value', 'scene')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
|
||||
await router.push({ query: { sourceShotId: 'shot-target', subjectFormId: 'form-other' } })
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.form-image-card').attributes('data-form-id')).toBe('form-other')
|
||||
const clear = wrapper!.findAll('button').find(button => button.text() === '清除定位')!
|
||||
await clear.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
})
|
||||
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!.findAll('button').find(button => button.text() === '刷新图库')!
|
||||
await refresh.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'
|
||||
])
|
||||
})
|
||||
it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => {
|
||||
const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
|
||||
keyframes.items[0]!.primaryKeyframeStale = false
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '刷新图库')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('[data-keyframe-impact]').text()).toContain('检查结果不一致')
|
||||
expect(wrapper!.get('.form-image-card').text()).toContain('不要据此重复生图')
|
||||
})
|
||||
it('镜头修复后清除过期警告,保留明确的当前状态和返回入口', async () => {
|
||||
const { keyframes, videos } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
|
||||
keyframes.items[0]!.primaryKeyframeStale = false
|
||||
videos.items[0]!.issues = []
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '刷新图库')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.find('[data-keyframe-impact]').exists()).toBe(false)
|
||||
expect(wrapper!.text()).toContain('未被标记为过期')
|
||||
})
|
||||
it('无效镜头和已移除形态不发跨项目请求,也不误定位其他素材', async () => {
|
||||
const { fetcher } = await gallery('?sourceShotId=foreign-shot&subjectFormId=deleted-form')
|
||||
expect(wrapper!.text()).toContain('来源镜头不存在或不属于当前项目')
|
||||
expect(wrapper!.get('[data-focused-material]').text()).toContain('指定形态不存在')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(0)
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).includes('foreign-shot/references'))).toBe(false)
|
||||
})
|
||||
it('影响查询失败不能当成没有过期首帧,仍然显示已有图库', async () => {
|
||||
const { fetcher } = await gallery()
|
||||
const original = fetcher.getMockImplementation()!
|
||||
fetcher.mockImplementation((url, init) =>
|
||||
String(url).includes('/readiness')
|
||||
? Promise.resolve(new Response('{}', { status: 500 }))
|
||||
: original(url, init)
|
||||
)
|
||||
await wrapper!
|
||||
.findAll('button')
|
||||
.find(button => button.text() === '刷新图库')!
|
||||
.trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper!.text()).toContain('暂时无法确认有无过期首帧')
|
||||
expect(wrapper!.findAll('.form-image-card')).toHaveLength(3)
|
||||
})
|
||||
it('素材返回生产页时按正式 Shot ID 选择,不误选同编号的另一个 Beat', async () => {
|
||||
const context = testProjectContext()
|
||||
context.data.value!.checkpoints = [storyboardCheckpoint()]
|
||||
const data = directionsResult('capability-test', 2)
|
||||
const target = data.beats[1]!.shots[0]!
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async url => {
|
||||
const path = String(url)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: path.includes('/storyboard-directions')
|
||||
? data
|
||||
: path.includes('/readiness') || path.endsWith('/videos/status')
|
||||
? { total: 0, items: [] }
|
||||
: []
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/projects/:projectId/production', component: ProductionPage }]
|
||||
})
|
||||
await router.push({
|
||||
path: '/projects/capability-test/production',
|
||||
query: { episodeNo: '2', shotId: target.shotId }
|
||||
})
|
||||
wrapper = mount(ProductionPage, {
|
||||
global: { plugins: [router], provide: { [projectContextKey as symbol]: context } }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper!.get('.production-detail').text()).toContain(target.shotId)
|
||||
expect(fetcher.mock.calls.some(([url]) => String(url).includes('episodeNo=2'))).toBe(true)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,223 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AssetImage } from '../../components/ui'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import GenerateImageDialog from './components/GenerateImageDialog.vue'
|
||||
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
|
||||
import { coverImage, hasRunningImages, primaryImage, validImageSize } from './model'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
import { expandSections } from '../../testing/naive'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
/** Naive 将弹窗挂载到 body,需要从实际弹窗找到按钮。 */
|
||||
function button(label: string): HTMLButtonElement {
|
||||
const element = [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)
|
||||
if (!element) throw new Error('找不到按钮:' + label)
|
||||
return element
|
||||
}
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
Object.assign(getOperation('gallery-test'), { pending: false, label: '', error: '', notice: '' })
|
||||
})
|
||||
|
||||
describe('形态图片选择与操作', () => {
|
||||
it('形态图库复用有界历史栏,多张候选与失败记录切换只更新预览', async () => {
|
||||
const rows = [
|
||||
imageFixture(),
|
||||
imageFixture({ id: 'candidate', isPrimary: false, imageUrl: '/storage/candidate.png' }),
|
||||
imageFixture({ id: 'failed', isPrimary: false, status: 'failed', imageUrl: null, error: '生成失败详情' })
|
||||
]
|
||||
const fetcher = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({ data: rows })))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ImageGalleryDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
const history = document.querySelector<HTMLElement>('[aria-label="图片历史"]')!
|
||||
expect(history.querySelectorAll('.image-history-item')).toHaveLength(3)
|
||||
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
|
||||
expect([...history.querySelectorAll('img')].every(image => image.style.objectFit === 'cover')).toBe(true)
|
||||
expect(history.previousElementSibling?.textContent).toContain('历史记录 · 3 条')
|
||||
expect(history.previousElementSibling?.previousElementSibling?.classList.contains('asset-image-preview')).toBe(
|
||||
true
|
||||
)
|
||||
history.querySelector<HTMLButtonElement>('[aria-label="查看图片 candidate"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.asset-image-preview img')?.getAttribute('src')).toContain(
|
||||
'/storage/candidate.png'
|
||||
)
|
||||
expect(history.querySelector('[aria-label="查看图片 candidate"]')?.getAttribute('aria-pressed')).toBe('true')
|
||||
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
|
||||
history.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click()
|
||||
await flushPromises()
|
||||
expect(document.querySelector('.asset-image-preview')?.textContent).toContain('本次生成失败')
|
||||
expect(button('设为主参考图').disabled).toBe(true)
|
||||
expect(fetcher).toHaveBeenCalledOnce()
|
||||
expect(fetcher.mock.calls[0]![1]?.method).toBe('GET')
|
||||
expect(wrapper.emitted('changed')).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each(['character', 'scene', 'prop'])('按后端最新 %s 模块展示母版继承范围', async module => {
|
||||
const form = formFixture()
|
||||
form.subject.module = module
|
||||
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).toContain('母版')
|
||||
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
|
||||
})
|
||||
it('已有主图优先于新候选图和失败记录,无主图才回退到成功候选图', () => {
|
||||
const form = formFixture()
|
||||
form.images.unshift(
|
||||
imageFixture({ id: 'failed', isPrimary: false, status: 'failed', imageUrl: null }),
|
||||
imageFixture({ id: 'candidate', isPrimary: false })
|
||||
)
|
||||
expect(coverImage(form)?.id).toBe('image-db-1')
|
||||
form.images.pop()
|
||||
expect(primaryImage(form.images)).toBeUndefined()
|
||||
expect(coverImage(form)?.id).toBe('candidate')
|
||||
expect(hasRunningImages(form.images)).toBe(false)
|
||||
form.images.push(imageFixture({ status: 'generating', isPrimary: false }))
|
||||
expect(hasRunningImages(form.images)).toBe(true)
|
||||
})
|
||||
|
||||
it('尺寸可同时留空,但不接受单边、零、负数或非整数', () => {
|
||||
expect(validImageSize('', '')).toBe(true)
|
||||
expect(validImageSize(2048, 2048)).toBe(true)
|
||||
for (const [width, height] of [
|
||||
[1024, ''],
|
||||
['', 1024],
|
||||
[0, 1024],
|
||||
[-1, 1024],
|
||||
[10.5, 1024]
|
||||
] as const)
|
||||
expect(validImageSize(width, height)).toBe(false)
|
||||
})
|
||||
|
||||
it('生图确认发送正式形态 ID,不覆盖后端模型,默认不替换已有主图', async () => {
|
||||
wrapper = mount(GenerateImageDialog, {
|
||||
attachTo: document.body,
|
||||
props: { open: true, form: formFixture(), disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(button('确认生成图片').disabled).toBe(true)
|
||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成图片').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]])
|
||||
})
|
||||
|
||||
it('新形态默认设主图,填写一侧尺寸时不能提交,切换形态清空自定义提示词', async () => {
|
||||
const form = formFixture()
|
||||
form.images = []
|
||||
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form, disabled: false } })
|
||||
await flushPromises()
|
||||
const width = document.querySelector<HTMLInputElement>('#image-width')!
|
||||
width.value = '2048'
|
||||
width.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
expect(button('确认生成图片').disabled).toBe(true)
|
||||
await wrapper.setProps({ form: { ...form, id: 'form-db-2' } })
|
||||
await flushPromises()
|
||||
expect(width.value).toBe('')
|
||||
expect(document.querySelector('#confirm-image-cost')!.getAttribute('aria-checked')).toBe('false')
|
||||
document.querySelector<HTMLInputElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
button('确认生成图片').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([['form-db-2', { setPrimary: true }]])
|
||||
})
|
||||
|
||||
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
|
||||
vi.useFakeTimers()
|
||||
let primary = false
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (_url, init) => {
|
||||
if (init?.method === 'PUT') {
|
||||
primary = true
|
||||
return new Response(JSON.stringify({ data: imageFixture() }))
|
||||
}
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
imageFixture({ isPrimary: primary }),
|
||||
imageFixture({
|
||||
id: 'failed',
|
||||
isPrimary: false,
|
||||
status: 'failed',
|
||||
imageUrl: null,
|
||||
error: '供应商拒绝请求'
|
||||
})
|
||||
]
|
||||
})
|
||||
)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ImageGalleryDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
await expandSections()
|
||||
expect(document.body.textContent).toContain('<script>模型提示词</script>')
|
||||
expect(document.querySelector('[role="dialog"] script')).toBeNull()
|
||||
button('设为主参考图').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls).toHaveLength(1)
|
||||
button('确认切换主图').click()
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.find(([, init]) => init?.method === 'PUT')?.[0]).toBe(
|
||||
'/api/subject-forms/form-db-1/images/image-db-1/primary'
|
||||
)
|
||||
expect(wrapper.emitted('changed')).toHaveLength(1)
|
||||
document.querySelector<HTMLButtonElement>('[aria-label="查看图片 failed"]')!.click()
|
||||
await flushPromises()
|
||||
expect(button('设为主参考图').disabled).toBe(true)
|
||||
expect(document.body.textContent).toContain('供应商拒绝请求')
|
||||
})
|
||||
|
||||
it('关闭图片弹窗取消查询,迟到的旧形态响应不会污染再次打开的形态', async () => {
|
||||
let finish!: (response: Response) => void
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise(resolve => {
|
||||
finish = resolve
|
||||
})
|
||||
)
|
||||
.mockImplementation(async () => new Response('{"data":[]}'))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(ImageGalleryDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'gallery-test', form: formFixture('gallery-test'), open: true, disabled: false }
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ open: false })
|
||||
expect(fetcher.mock.calls[0]?.[1]?.signal?.aborted).toBe(true)
|
||||
await wrapper.setProps({ open: true, form: { ...formFixture('gallery-test'), id: 'form-db-2' } })
|
||||
await flushPromises()
|
||||
finish(new Response(JSON.stringify({ data: [imageFixture()] })))
|
||||
await flushPromises()
|
||||
expect(document.body.textContent).not.toContain('Image ID · image-db-1')
|
||||
expect(document.body.textContent).toContain('此形态尚无图片记录')
|
||||
})
|
||||
|
||||
it('图片加载失败有占位,地址变化可恢复,危险协议不会写入 img', async () => {
|
||||
wrapper = mount(AssetImage, { props: { src: '/storage/a.png', alt: '形态主图' } })
|
||||
await wrapper.get('img').trigger('error')
|
||||
expect(wrapper.text()).toContain('图片无法加载')
|
||||
await wrapper.setProps({ src: '/storage/b.png' })
|
||||
expect(wrapper.get('img').attributes('src')).toContain('/storage/b.png')
|
||||
await wrapper.setProps({ src: 'javascript:alert(1)' })
|
||||
expect(wrapper.find('img').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,31 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formCoverAspectRatio } from './layout'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
|
||||
describe('图库瀑布流图片比例', () => {
|
||||
it.each([
|
||||
[800, 1200],
|
||||
[1600, 900],
|
||||
[1024, 1024]
|
||||
])('保留正式图片尺寸 %s × %s', (width, height) => {
|
||||
const form = formFixture()
|
||||
form.images = [imageFixture({ width, height })]
|
||||
expect(formCoverAspectRatio(form)).toBe(`${width} / ${height}`)
|
||||
})
|
||||
|
||||
it.each([null, 0, -1, NaN, Infinity])('尺寸 %s 无效时使用稳定占位比例', width => {
|
||||
const form = formFixture()
|
||||
form.images = [imageFixture({ width })]
|
||||
expect(formCoverAspectRatio(form)).toBe('4 / 3')
|
||||
form.images = [imageFixture({ height: width })]
|
||||
expect(formCoverAspectRatio(form)).toBe('4 / 3')
|
||||
})
|
||||
|
||||
it('无图片时保留占位,候选封面也使用自己的尺寸', () => {
|
||||
const form = formFixture()
|
||||
form.images = []
|
||||
expect(formCoverAspectRatio(form)).toBe('4 / 3')
|
||||
form.images = [imageFixture({ isPrimary: false, width: 800, height: 1200 })]
|
||||
expect(formCoverAspectRatio(form)).toBe('800 / 1200')
|
||||
})
|
||||
})
|
||||
@@ -1,187 +0,0 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectContextKey } from '../projects/context'
|
||||
import { testProjectContext } from '../../testing/project-context'
|
||||
import { getOperation } from '../workflows/operations'
|
||||
import { formFixture, imageFixture } from './testing/fixtures'
|
||||
import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from './model'
|
||||
import { useSubjectImages } from './useSubjectImages'
|
||||
import FormPromptDialog from './components/FormPromptDialog.vue'
|
||||
import GenerateImageDialog from './components/GenerateImageDialog.vue'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
const projectId = 'capability-test'
|
||||
/** 从实际挂载的确认弹窗查找操作按钮。 */
|
||||
function find(label: string) {
|
||||
return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)!
|
||||
}
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
Object.assign(getOperation(projectId), { pending: false, error: '', notice: '', label: '' })
|
||||
Object.assign(getImageSession(projectId), { receipt: null, promptReceipt: null })
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
let service!: ReturnType<typeof useSubjectImages>
|
||||
const context = testProjectContext()
|
||||
const form = formFixture(projectId)
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (url, init) => {
|
||||
const path = String(url)
|
||||
let data: unknown = [form]
|
||||
if (init?.method === 'POST') {
|
||||
if (path.endsWith('/generation-prompts'))
|
||||
data = {
|
||||
total: 2,
|
||||
targetCount: 2,
|
||||
generated: 1,
|
||||
skipped: 0,
|
||||
failed: 1,
|
||||
failures: [{ subjectFormId: 'form-failed', error: '模型拒绝' }]
|
||||
}
|
||||
else if (path.endsWith('/generation-prompt')) {
|
||||
form.generationPrompt = '正式提示词'
|
||||
data = { id: form.id, subjectId: form.subjectId, generationPrompt: form.generationPrompt }
|
||||
} else if (path.includes('/subject-forms/')) data = imageFixture()
|
||||
else
|
||||
data = {
|
||||
total: 3,
|
||||
targetCount: 1,
|
||||
generated: 1,
|
||||
skipped: 0,
|
||||
failed: 0,
|
||||
failures: [],
|
||||
eligibleCount: 3,
|
||||
remaining: 2
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ data }))
|
||||
})
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(
|
||||
defineComponent({
|
||||
setup() {
|
||||
service = useSubjectImages()
|
||||
return () => null
|
||||
}
|
||||
}),
|
||||
{ global: { provide: { [projectContextKey as symbol]: context } } }
|
||||
)
|
||||
await flushPromises()
|
||||
return {
|
||||
service,
|
||||
context,
|
||||
form,
|
||||
fetcher,
|
||||
posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST')
|
||||
}
|
||||
}
|
||||
|
||||
describe('形态正式提示词与批量配置', () => {
|
||||
it('单个使用正式形态 ID 和 force,生成提示词不会调用图片接口', async () => {
|
||||
const { service, posts } = await setup()
|
||||
await service.generatePrompt('unknown-form', false)
|
||||
expect(posts()).toHaveLength(0)
|
||||
await service.generatePrompt('form-db-1', false)
|
||||
expect(posts()).toHaveLength(1)
|
||||
expect(posts()[0]?.[0]).toBe('/api/subject-forms/form-db-1/generation-prompt')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ force: false })
|
||||
expect(service.forms.value[0]?.generationPrompt).toBe('正式提示词')
|
||||
await service.generatePrompt('form-db-1', true)
|
||||
expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ force: true })
|
||||
})
|
||||
it('提示词接口返回空正文时显示失败,不误报保存成功', async () => {
|
||||
const { service, fetcher } = await setup()
|
||||
fetcher.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ data: { id: 'form-db-1', subjectId: 'subject-db-1', generationPrompt: '' } }))
|
||||
)
|
||||
await service.generatePrompt('form-db-1', false)
|
||||
expect(getOperation(projectId).error).toContain('未确认正式提示词已保存')
|
||||
})
|
||||
it('批量提示词保留部分失败回执,不覆盖图片回执且不传图片上限', async () => {
|
||||
const { service, posts } = await setup()
|
||||
service.limit.value = 1
|
||||
service.promptConcurrency.value = 4
|
||||
service.promptForce.value = true
|
||||
await service.generatePrompts()
|
||||
expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/subject-forms/generation-prompts')
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ concurrency: 4, force: true })
|
||||
expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝')
|
||||
expect(service.session.value.receipt).toBeNull()
|
||||
})
|
||||
it('图片数量上限可选,非法上限和并发阻止提交', async () => {
|
||||
const { service, posts } = await setup()
|
||||
for (const limit of [0, -1, 1.5]) {
|
||||
service.limit.value = limit
|
||||
await service.generateProject()
|
||||
}
|
||||
expect(posts()).toHaveLength(0)
|
||||
service.limit.value = 1
|
||||
await service.generateProject()
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({
|
||||
concurrency: 2,
|
||||
force: false,
|
||||
limit: 1
|
||||
})
|
||||
expect(service.session.value.receipt?.result.remaining).toBe(2)
|
||||
service.limit.value = ''
|
||||
await service.generateProject()
|
||||
expect(JSON.parse(String(posts()[1]?.[1]?.body))).not.toHaveProperty('limit')
|
||||
service.promptConcurrency.value = 0
|
||||
await service.generatePrompts()
|
||||
expect(posts()).toHaveLength(2)
|
||||
})
|
||||
it.each(['draft', 'generating', 'need_review', 'failed'] as const)('%s 剧本不能生成提示词和图片', async status => {
|
||||
const { service, context, posts } = await setup()
|
||||
context.data.value!.project.status = status
|
||||
await service.generatePrompt('form-db-1', true)
|
||||
await service.generatePrompts()
|
||||
await service.generateProject()
|
||||
expect(posts()).toHaveLength(0)
|
||||
})
|
||||
it.each(['character', 'scene', 'prop'])('%s 已锁定母版才参与过期判断,刷新只新增候选', async module => {
|
||||
const { service, form, posts } = await setup()
|
||||
form.subject.module = module
|
||||
form.subject.identity = { id: 'identity', isLocked: false, images: [{ id: 'anchor-new' }] }
|
||||
expect(currentIdentityAnchorId(form)).toBeUndefined()
|
||||
expect(isPrimaryIdentityStale(form)).toBe(false)
|
||||
form.subject.identity.isLocked = true
|
||||
expect(isPrimaryIdentityStale(form)).toBe(true)
|
||||
await service.query.refresh()
|
||||
await service.generateStale()
|
||||
expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ setPrimary: false })
|
||||
})
|
||||
it('正式提示词确认必须勾选,未确认不发送事件', async () => {
|
||||
const form = formFixture()
|
||||
wrapper = mount(FormPromptDialog, { attachTo: document.body, props: { open: true, disabled: false, form } })
|
||||
await flushPromises()
|
||||
find('生成正式提示词').click()
|
||||
await flushPromises()
|
||||
expect(find('确认生成正式提示词').disabled).toBe(true)
|
||||
expect(wrapper.emitted('generate')).toBeUndefined()
|
||||
document.querySelector<HTMLElement>('.app-dialog [role="checkbox"]')!.click()
|
||||
await flushPromises()
|
||||
find('确认生成正式提示词').click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([['form-db-1', false]])
|
||||
})
|
||||
it('缺少正式和原始提示词仍可确认生图,由后端补齐而非前端编造', async () => {
|
||||
const form = formFixture()
|
||||
form.appearancePrompt = null
|
||||
form.generationPrompt = null
|
||||
wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, disabled: false, form } })
|
||||
await flushPromises()
|
||||
document.querySelector<HTMLElement>('#confirm-image-cost')!.click()
|
||||
await flushPromises()
|
||||
const submit = [...document.querySelectorAll('button')].find(
|
||||
item => item.textContent?.trim() === '确认生成图片'
|
||||
)!
|
||||
expect(submit.disabled).toBe(false)
|
||||
submit.click()
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]])
|
||||
})
|
||||
})
|
||||
@@ -165,3 +165,26 @@ function removeImage(image: VisualStyleImage) {
|
||||
@remove="removeImage"
|
||||
/></WorkspacePage>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.visual-style-toolbar {
|
||||
@apply flex items-center justify-between flex-wrap gap-y-4 gap-x-6 my-5 py-3.5 px-4 bg-(--app-subtle);
|
||||
}
|
||||
.visual-style-status {
|
||||
@apply flex items-center gap-3 min-w-0;
|
||||
}
|
||||
.visual-style-status > svg {
|
||||
@apply shrink-0 text-muted;
|
||||
}
|
||||
.visual-style-status strong {
|
||||
@apply text-sm font-medium;
|
||||
}
|
||||
.visual-style-status p {
|
||||
@apply mt-1 mx-0 mb-0 text-muted text-xs leading-[1.6];
|
||||
}
|
||||
.visual-style-actions {
|
||||
@apply flex items-center flex-wrap gap-2.5 ml-auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -56,3 +56,11 @@ function confirm() {
|
||||
</AppDialog>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.confirm-action {
|
||||
@apply inline-flex items-center max-w-full;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -54,3 +54,27 @@ const history = computed(() => workflowCheckpoints(props.checkpoints, props.work
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.history-panel {
|
||||
@apply py-[23px] px-[21px] bg-(--app-subtle);
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.history-panel {
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
.history-panel {
|
||||
@apply p-0;
|
||||
}
|
||||
.history-content {
|
||||
@apply py-[23px] px-[21px];
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.history-panel {
|
||||
@apply overflow-hidden min-h-0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -161,3 +161,20 @@ function exportReport() {
|
||||
/>
|
||||
</AppDialog>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* 本组件专属布局与 Naive 内部结构覆盖。 */
|
||||
@reference "../../styles/styles.css";
|
||||
.diagnostics-filters {
|
||||
@apply flex flex-wrap items-center gap-3;
|
||||
}
|
||||
.diagnostics-filters > .n-input {
|
||||
@apply flex-[1_1_240px] min-w-0;
|
||||
}
|
||||
.diagnostics-filters > .n-select {
|
||||
@apply flex-[0_1_180px] min-w-0;
|
||||
}
|
||||
.diagnostics-records {
|
||||
@apply grid gap-3 min-w-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import WorkflowDiagnosticsDialog from './WorkflowDiagnosticsDialog.vue'
|
||||
import { loadWorkflowDiagnostics, type WorkflowTimelineGroup } from './diagnostics'
|
||||
import type { Checkpoint } from './types'
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = undefined
|
||||
document.body.innerHTML = ''
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** 后端将 checkpoint 固定标为 completed,测试确保界面不把它当执行成功。 */
|
||||
const groups: WorkflowTimelineGroup[] = [
|
||||
{
|
||||
phase: '其它',
|
||||
nodeCount: 22,
|
||||
durationMs: 22000,
|
||||
durationText: '22s',
|
||||
nodes: Array.from({ length: 22 }, (_, index) => ({
|
||||
index: index + 1,
|
||||
checkpointId: `checkpoint-${index}`,
|
||||
nodeName: `node-${index}`,
|
||||
phase: '其它',
|
||||
status: 'completed',
|
||||
durationMs: 1000,
|
||||
durationText: '1s',
|
||||
retryCount: 0,
|
||||
createdAt: '2026-09-01T00:00:00Z'
|
||||
}))
|
||||
}
|
||||
]
|
||||
const metrics = {
|
||||
projectId: 'diagnostics-test',
|
||||
nodeCount: 22,
|
||||
totalDurationText: '22s',
|
||||
retryCount: 1,
|
||||
successRate: 100,
|
||||
failedNodeCount: 0
|
||||
}
|
||||
|
||||
describe('运行观测入口', () => {
|
||||
it('关闭时不请求,打开只读两个接口,完整展示超过 18 条记录且不伪造成功率', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(
|
||||
async url => new Response(JSON.stringify({ data: String(url).endsWith('/metrics') ? metrics : groups }))
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const checkpoints: Checkpoint[] = [
|
||||
{
|
||||
checkpointId: 'checkpoint-0',
|
||||
workflowName: 'breakdown',
|
||||
createdAt: '2026-09-01T00:00:00Z',
|
||||
state: {
|
||||
workflowExecution: {
|
||||
status: 'failed',
|
||||
executionId: 'execution-1',
|
||||
startedAt: '2026-09-01T00:00:00Z'
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
wrapper = mount(WorkflowDiagnosticsDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'diagnostics-test', open: false, checkpoints }
|
||||
})
|
||||
await flushPromises()
|
||||
expect(fetcher).not.toHaveBeenCalled()
|
||||
await wrapper.setProps({ open: true })
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(22)
|
||||
expect(document.body.textContent).toContain('包含失败记录')
|
||||
expect(document.body.textContent).not.toContain('100%')
|
||||
const input = document.querySelector<HTMLInputElement>('[aria-label="搜索运行记录"]')!
|
||||
input.value = 'checkpoint-21'
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(1)
|
||||
})
|
||||
it('一个观测接口失败仍显示另一项,不启动恢复或生产', async () => {
|
||||
const fetcher = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(async url =>
|
||||
String(url).endsWith('/metrics')
|
||||
? new Response(JSON.stringify({ error: '指标不可用' }), { status: 500 })
|
||||
: new Response(JSON.stringify({ data: groups }))
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
const result = await loadWorkflowDiagnostics('project/1')
|
||||
expect(result.metrics).toBeNull()
|
||||
expect(result.groups?.[0]?.nodes).toHaveLength(22)
|
||||
expect(result.errors[0]).toContain('指标')
|
||||
expect(fetcher.mock.calls[0]?.[0]).toBe('/api/projects/project%2F1/metrics')
|
||||
expect(fetcher.mock.calls[1]?.[0]).toBe('/api/projects/project%2F1/timeline/grouped')
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.method === 'GET')).toBe(true)
|
||||
})
|
||||
it('指标项目不匹配时拒绝展示', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn<typeof fetch>()
|
||||
.mockImplementation(
|
||||
async url => new Response(JSON.stringify({ data: String(url).endsWith('/metrics') ? metrics : [] }))
|
||||
)
|
||||
)
|
||||
const result = await loadWorkflowDiagnostics('other-project')
|
||||
expect(result.metrics).toBeNull()
|
||||
expect(result.errors.join()).toContain('不匹配的项目')
|
||||
})
|
||||
it('切项目会取消旧查询,晚到响应不会写进新项目', async () => {
|
||||
const pending: ((response: Response) => void)[] = []
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(url =>
|
||||
String(url).includes('/old/')
|
||||
? new Promise(resolve => pending.push(resolve))
|
||||
: Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
data: String(url).endsWith('/metrics')
|
||||
? { ...metrics, projectId: 'new', nodeCount: 0 }
|
||||
: []
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(WorkflowDiagnosticsDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'old', open: true, checkpoints: [] }
|
||||
})
|
||||
await flushPromises()
|
||||
const signal = fetcher.mock.calls[0]?.[1]?.signal
|
||||
await wrapper.setProps({ projectId: 'new' })
|
||||
await flushPromises()
|
||||
expect(signal?.aborted).toBe(true)
|
||||
pending[0]!(new Response(JSON.stringify({ data: { ...metrics, projectId: 'old' } })))
|
||||
pending[1]!(new Response(JSON.stringify({ data: groups })))
|
||||
await flushPromises()
|
||||
expect(document.querySelectorAll('.diagnostics-records article')).toHaveLength(0)
|
||||
expect(document.body.textContent).toContain('全项目记录 0')
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
it('关闭弹窗时取消仍未返回的查询', async () => {
|
||||
const finishes: ((response: Response) => void)[] = []
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(() => new Promise(resolve => finishes.push(resolve)))
|
||||
vi.stubGlobal('fetch', fetcher)
|
||||
wrapper = mount(WorkflowDiagnosticsDialog, {
|
||||
attachTo: document.body,
|
||||
props: { projectId: 'diagnostics-test', open: true, checkpoints: [] }
|
||||
})
|
||||
await flushPromises()
|
||||
await wrapper.setProps({ open: false })
|
||||
await flushPromises()
|
||||
expect(fetcher.mock.calls.every(([, init]) => init?.signal?.aborted)).toBe(true)
|
||||
finishes[0]!(new Response(JSON.stringify({ data: metrics })))
|
||||
finishes[1]!(new Response(JSON.stringify({ data: groups })))
|
||||
await flushPromises()
|
||||
expect(fetcher).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -1,20 +0,0 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getOperation, runOperation } from './operations'
|
||||
|
||||
describe('项目级长请求互斥', () => {
|
||||
it('同一项目不同时执行两个 graph,错误不伪装成功', async () => {
|
||||
let fail!: (reason: Error) => void
|
||||
const action = vi.fn<() => Promise<unknown>>(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
fail = reject
|
||||
})
|
||||
)
|
||||
const first = runOperation('operation-test', '拆解', action)
|
||||
expect(await runOperation('operation-test', '改写', action)).toBe(false)
|
||||
expect(action).toHaveBeenCalledTimes(1)
|
||||
fail(new Error('连接中断'))
|
||||
expect(await first).toBe(false)
|
||||
expect(getOperation('operation-test')).toMatchObject({ pending: false, error: '连接中断', notice: '' })
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { breakdownSnapshot, recoveryOptions, workflowCheckpoints } from './selectors'
|
||||
import type { Checkpoint } from './types'
|
||||
import type { BreakdownState } from '../breakdown/types'
|
||||
|
||||
/** 只构造测试所需的真实 checkpoint 字段,避免页面依赖虚构 DTO。 */
|
||||
function checkpoint(index: number, state: BreakdownState, workflowName = 'breakdown'): Checkpoint {
|
||||
return { checkpointId: String(index), workflowName, createdAt: new Date(index * 1000).toISOString(), state }
|
||||
}
|
||||
|
||||
describe('Checkpoint 选择与恢复', () => {
|
||||
it('按 graph 隔离并保持输入不可变', () => {
|
||||
const records = [checkpoint(2, {}), checkpoint(1, {}, 'create-drama')]
|
||||
expect(workflowCheckpoints(records, 'breakdown').map(item => item.checkpointId)).toEqual(['2'])
|
||||
expect(records[0]?.checkpointId).toBe('2')
|
||||
})
|
||||
|
||||
it('只有错误的最新 checkpoint 仍能展示上一份成果,同时保留最新失败状态', () => {
|
||||
const result = breakdownSnapshot([
|
||||
checkpoint(1, {
|
||||
breakdownResult: { subjectCandidates: [] },
|
||||
runConfig: { groupSize: 3, modules: ['character'], episodeGroups: [] }
|
||||
}),
|
||||
checkpoint(2, {
|
||||
workflowExecution: {
|
||||
executionId: 'e',
|
||||
status: 'failed',
|
||||
startedAt: '',
|
||||
errorMessage: '模型未返回 JSON'
|
||||
}
|
||||
})
|
||||
])
|
||||
expect(result?.runConfig?.groupSize).toBe(3)
|
||||
expect(result?.workflowExecution?.status).toBe('failed')
|
||||
})
|
||||
|
||||
it('新阶段不能继承上一轮 completed 状态', () => {
|
||||
const result = breakdownSnapshot([
|
||||
checkpoint(1, {
|
||||
workflowExecution: { executionId: 'e', status: 'completed', startedAt: '' },
|
||||
breakdownResult: {}
|
||||
}),
|
||||
checkpoint(2, { runConfig: { groupSize: 2, modules: ['scene'], episodeGroups: [] } })
|
||||
])
|
||||
expect(result?.workflowExecution).toBeUndefined()
|
||||
expect(result?.runConfig?.groupSize).toBe(2)
|
||||
})
|
||||
|
||||
it('恢复窗口和后端一致,过旧的失败任务不启用重试', () => {
|
||||
const records = Array.from({ length: 11 }, (_, index) =>
|
||||
checkpoint(
|
||||
index,
|
||||
index === 0
|
||||
? {
|
||||
tasks: [
|
||||
{
|
||||
taskId: 't',
|
||||
module: 'prop',
|
||||
status: 'failed',
|
||||
attempt: 1,
|
||||
group: { groupId: 'g', groupNo: 1, startEpisodeNo: 1, endEpisodeNo: 1, episodes: [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
: {}
|
||||
)
|
||||
)
|
||||
expect(recoveryOptions(records).retry).toBe(false)
|
||||
expect(recoveryOptions(records.slice(0, 10)).retry).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user