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().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: '
' } } ] }) 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('.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') expect(wrapper!.get('[aria-label="网格布局"]').classes()).toContain('layout-option-active') 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!.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!.findAll('.form-image-card').map(card => card.element)).toEqual(cards) expect(scroll.scrollTop).toBe(240) expect( wrapper!.get('.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!.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!.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('.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().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: '
' } }] }) 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().mockResolvedValue(new Response('{}', { status: 500 }))) const router = createRouter({ history: createMemoryHistory(), routes: [{ path: '/:pathMatch(.*)*', component: { template: '
' } }] }) 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: '
' } }] }) 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, images: [{ id: 'anchor-new' }] } const refresh = wrapper!.get('[aria-label="刷新图库"]') 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!.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().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) }) })