feat: 收口组件样式并调整移动端侧栏

This commit is contained in:
GJ
2026-09-04 17:19:34 +08:00
parent d252d5513f
commit e6e0f08072
68 changed files with 2717 additions and 2775 deletions
@@ -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 }]])
})
})