Files

509 lines
26 KiB
TypeScript

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 { first } = await gallery()
first.images = []
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
const card = wrapper!.findAll('.form-image-card').find(item => item.attributes('data-form-id') === first.id)!
expect(card.get('.asset-image').classes()).toContain('form-image-empty')
})
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('.form-image-masonry').element).toBe(grid)
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('true')
expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).toContain('layout-option-active')
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('false')
expect(wrapper!.get('[aria-label="网格布局"]').classes()).not.toContain('layout-option-active')
expect(
wrapper!
.get('.gallery-layout-switch')
.findAll('button')
.map(button => button.attributes('aria-label'))
).toEqual(['网格布局', '瀑布流布局', '刷新图库', '批量生图'])
await wrapper!.get('[aria-label="网格布局"]').trigger('click')
expect(wrapper!.find('.form-image-masonry').exists()).toBe(false)
expect(wrapper!.get('[aria-label="网格布局"]').attributes('aria-pressed')).toBe('true')
expect(wrapper!.get('[aria-label="网格布局"]').classes()).toContain('layout-option-active')
expect(wrapper!.get('[aria-label="瀑布流布局"]').attributes('aria-pressed')).toBe('false')
expect(wrapper!.get('[aria-label="瀑布流布局"]').classes()).not.toContain('layout-option-active')
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('')
await wrapper!.get('input[aria-label="搜索形态图片"]').setValue('雨夜')
await wrapper!.get('[aria-label="瀑布流布局"]').trigger('click')
expect(wrapper!.get('.form-image-masonry').element).toBe(grid)
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!.get('[aria-label="刷新图库"]')
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!.get('[aria-label="刷新图库"]').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
.findAllComponents(NSelect)
.some((item: VueWrapper) => item.attributes('aria-label') === '筛选图片状态')
).toBe(true)
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, anchorImageId: 'anchor-new' }
await wrapper!.get('[aria-label="刷新图库"]').trigger('click')
await flushPromises()
expect(wrapper!.text()).not.toContain('形态主图需要更新')
expect(router.currentRoute.value.query.subjectFormId).toBe('form-other')
expect(wrapper!.findAll('button').some(item => item.text() === '重新生成过期形态')).toBe(false)
})
it('仅视频接口报告过期时也显示警告,并明确两种检查不一致', async () => {
const { keyframes } = await gallery('?sourceShotId=shot-target&subjectFormId=form-special')
keyframes.items[0]!.primaryKeyframeStale = false
await wrapper!.get('[aria-label="刷新图库"]').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!.get('[aria-label="刷新图库"]').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!.get('[aria-label="刷新图库"]').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)
})
})