feat: 收口组件样式并调整移动端侧栏
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
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 '@/features/projects/context'
|
||||
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
|
||||
import { referenceLocation, referenceTargets } from '@/features/production/asset-links'
|
||||
import StaleKeyframeNotice from '@/features/production/components/StaleKeyframeNotice.vue'
|
||||
import SubjectImagesPage from '@/features/subject-images/SubjectImagesPage.vue'
|
||||
import ProductionPage from '@/features/production/ProductionPage.vue'
|
||||
import { directionsResult, storyboardCheckpoint } from '@/features/storyboard/testing/fixtures'
|
||||
import type { ProductionIssue } from '@/features/production/types'
|
||||
import type { ShotReferences } from '@/features/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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,223 @@
|
||||
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 '@/features/workflows/operations'
|
||||
import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
|
||||
import ImageGalleryDialog from '@/features/subject-images/components/ImageGalleryDialog.vue'
|
||||
import { coverImage, hasRunningImages, primaryImage, validImageSize } from '@/features/subject-images/model'
|
||||
import { formFixture, imageFixture } from '@/features/subject-images/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)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formCoverAspectRatio } from '@/features/subject-images/layout'
|
||||
import { formFixture, imageFixture } from '@/features/subject-images/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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { defineComponent } from 'vue'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { projectContextKey } from '@/features/projects/context'
|
||||
import { testProjectContext } from '@/testing/project-context'
|
||||
import { getOperation } from '@/features/workflows/operations'
|
||||
import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures'
|
||||
import { currentIdentityAnchorId, getImageSession, isPrimaryIdentityStale } from '@/features/subject-images/model'
|
||||
import { useSubjectImages } from '@/features/subject-images/useSubjectImages'
|
||||
import FormPromptDialog from '@/features/subject-images/components/FormPromptDialog.vue'
|
||||
import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue'
|
||||
|
||||
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 }]])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user