import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import { NImage, NScrollbar } from 'naive-ui' import { AssetImage } from '@/components/ui' import IdentityImageDialog from '@/features/subject-identity/components/IdentityImageDialog.vue' import IdentityGallery from '@/features/subject-identity/components/IdentityGallery.vue' import CastingCandidateDialog from '@/features/subject-identity/components/CastingCandidateDialog.vue' import { subjectIdentityApi } from '@/features/subject-identity/api' import { canBeAnchor, castingStatusLabel, currentAnchor, groupIdentitySubjects, mergeCastingSubjects, readImageProvenance } from '@/features/subject-identity/model' import { identityImageFixture } from '@/features/subject-identity/testing/fixtures' import { formFixture } from '@/features/subject-images/testing/fixtures' import { expandSections, selectControl } from '@/testing/naive' let wrapper: VueWrapper | undefined afterEach(() => { wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' vi.unstubAllGlobals() }) /** 从真实 Naive 弹窗内获取确认按钮。 */ function button(label: string) { const item = [...document.querySelectorAll('button')].find(element => element.textContent?.trim() === label) if (!item) throw new Error(`缺少按钮 ${label}`) return item } /** 修改 Portal 表单控件并触发 Vue 绑定。 */ function input(selector: string, value: string) { if (selector === '#identity-view' || selector === '#identity-reference') { selectControl(wrapper!, 'id', selector.slice(1)).vm.$emit('update:value', value) return } const item = document.querySelector(selector)! item.value = value item.dispatchEvent(new Event(item.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true })) } describe('身份图与母版契约', () => { it('详情大图 contain 完整显示,历史仍为 cover,缩略图只切换记录', async () => { wrapper = mount(IdentityGallery, { attachTo: document.body, props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [ identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png' }), identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false, imageUrl: '/storage/front.png' }) ] } }) const images = wrapper.findAllComponents(NImage) expect(images).toHaveLength(3) expect(images.map(image => image.props('objectFit'))).toEqual(['contain', 'cover', 'cover']) expect(images.map(image => image.props('previewDisabled'))).toEqual([false, true, true]) await wrapper.get('[aria-label="查看身份图片 front"] img').trigger('click') await flushPromises() expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true') expect(document.querySelector('.n-image-preview-container')).toBeNull() const main = wrapper.get('.asset-image-preview img') expect((main.element as HTMLImageElement).style.objectFit).toBe('contain') expect(main.attributes('src')).toContain('/storage/front.png') await main.trigger('click') await flushPromises() const original = document.querySelector('.n-image-preview')! expect(original.getAttribute('src')).toBe(main.attributes('src')) expect(original.style.objectFit).not.toBe('cover') expect(wrapper.emitted('anchor')).toBeUndefined() }) it('实际提示词默认展开,可手动收起,轮询更新不强行重新展开', async () => { const image = identityImageFixture({ prompt: '本次实际生成提示词' }) wrapper = mount(IdentityGallery, { props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [image] } }) const panel = wrapper.get('.n-collapse-item') expect(panel.classes()).toContain('n-collapse-item--active') expect(panel.text()).toContain('本次实际生成提示词') await panel.get('.n-collapse-item__header-main').trigger('click') expect(panel.classes()).not.toContain('n-collapse-item--active') await wrapper.setProps({ images: [{ ...image, prompt: '更新后的实际提示词' }] }) expect(panel.classes()).not.toContain('n-collapse-item--active') await panel.get('.n-collapse-item__header-main').trigger('click') expect(panel.classes()).toContain('n-collapse-item--active') expect(panel.text()).toContain('更新后的实际提示词') }) it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => { const images = [ identityImageFixture({ id: 'candidate', imageUrl: '/storage/candidate.png', isAnchor: false, enabled: false }), identityImageFixture({ id: 'failed', status: 'failed', imageUrl: null, isAnchor: false, enabled: false, error: '图片模型超时' }), identityImageFixture({ id: 'anchor', imageUrl: '/storage/anchor.png', width: 4096, height: 4096 }), identityImageFixture({ id: 'front', imageUrl: '/storage/front.png', viewType: 'front', isAnchor: false }), identityImageFixture({ id: 'pending', status: 'pending', imageUrl: null, isAnchor: false, enabled: false }), identityImageFixture({ id: 'generating', status: 'generating', imageUrl: null, isAnchor: false, enabled: false }) ] wrapper = mount(IdentityGallery, { attachTo: document.body, props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images } }) const history = wrapper.get('[aria-label="身份图片历史"]') expect(history.findAll('.image-history-item')).toHaveLength(6) expect(wrapper.getComponent(NScrollbar).props()).toMatchObject({ xScrollable: true, trigger: 'none', contentStyle: { width: 'max-content' } }) expect(wrapper.get('.asset-image-preview').element.nextElementSibling?.textContent).toContain('历史记录 · 6 条') expect(wrapper.get('.image-history-heading').element.nextElementSibling).toBe(history.element) expect(history.text()).toContain('当前母版') expect(history.text()).toContain('母版候选') expect(history.text()).toContain('正面') expect(history.text()).toContain('生成失败') expect(history.text()).toContain('排队中') expect(history.text()).toContain('生成中') expect(history.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true') for (const image of images) { await history.get(`[aria-label="查看身份图片 ${image.id}"]`).trigger('click') expect(history.findAll('[aria-pressed="true"]')).toHaveLength(1) expect(history.get(`[aria-label="查看身份图片 ${image.id}"]`).attributes('aria-pressed')).toBe('true') const preview = wrapper .findAllComponents(AssetImage) .find(item => item.classes().includes('asset-image-preview'))! expect(preview.props('src')).toBe(image.status === 'completed' ? image.imageUrl : null) expect(wrapper.text()).toContain(`Identity image ID · ${image.id}`) } await history.get('[aria-label="查看身份图片 failed"]').trigger('click') expect(wrapper.get('.asset-image-preview').text()).toContain('本次生成失败') expect(wrapper.findAll('[role="alert"]').map(item => item.text())).toContain('图片模型超时') expect(button('确认选角并锁定').disabled).toBe(true) expect(wrapper.emitted('anchor')).toBeUndefined() }) it('轮询新增历史不会切回母版或重置横向位置,当前记录移除后才回退', async () => { const anchor = identityImageFixture({ id: 'anchor' }) const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false }) wrapper = mount(IdentityGallery, { props: { subjectName: '林夏', module: 'character', identityLocked: true, disabled: false, images: [anchor, front] } }) await wrapper.get('[aria-label="查看身份图片 front"]').trigger('click') const viewport = wrapper.get('.image-history .n-scrollbar-container').element viewport.scrollLeft = 120 await wrapper.setProps({ images: [identityImageFixture({ id: 'new', isAnchor: false }), anchor, front] }) expect(wrapper.get('[aria-label="查看身份图片 front"]').attributes('aria-pressed')).toBe('true') expect(wrapper.get('.image-history .n-scrollbar-container').element).toBe(viewport) expect(viewport.scrollLeft).toBe(120) await wrapper.setProps({ images: [anchor] }) expect(wrapper.get('[aria-label="查看身份图片 anchor"]').attributes('aria-pressed')).toBe('true') expect(wrapper.get('.image-history-heading').text()).toContain('历史记录 · 1 条') }) it('切换历史取消上一张的确认,只有再次确认才能发出当前候选 ID', async () => { wrapper = mount(IdentityGallery, { attachTo: document.body, props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: ['candidate-a', 'candidate-b'].map(id => identityImageFixture({ id, isAnchor: false, enabled: false }) ) } }) button('确认选角并锁定').click() await flushPromises() expect(button('确认演员选择').disabled).toBe(false) await wrapper.get('[aria-label="查看身份图片 candidate-b"]').trigger('click') expect(wrapper.text()).not.toContain('确认选择这张图片作为正式演员') expect(wrapper.emitted('anchor')).toBeUndefined() button('确认选角并锁定').click() await flushPromises() button('确认演员选择').click() expect(wrapper.emitted('anchor')).toEqual([['candidate-b']]) }) it('无历史图片时只展示空状态,不伪造缩略图或母版', () => { wrapper = mount(IdentityGallery, { props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [] } }) expect(wrapper.text()).toContain('身份参考图 · 0') expect(wrapper.text()).toContain('尚无身份参考图') expect(wrapper.find('.image-history').exists()).toBe(false) expect(wrapper.find('.asset-image-preview').exists()).toBe(false) }) it('启用的辅助视角不是母版,primary 候选停用时仍可选为母版', () => { const front = identityImageFixture({ id: 'front', viewType: 'front', isAnchor: false }) const candidate = identityImageFixture({ id: 'candidate', enabled: false, isAnchor: false }) expect(canBeAnchor(front)).toBe(false) expect(canBeAnchor(candidate)).toBe(true) expect(currentAnchor([front, candidate])).toBeUndefined() expect(canBeAnchor(identityImageFixture({ status: 'failed' }))).toBe(false) expect(canBeAnchor(identityImageFixture({ imageUrl: null }))).toBe(false) }) it('正式主体关联校验不接受不同主体的 form,追溯 JSON 兼容旧数据', () => { expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配') expect(readImageProvenance('{bad')).toEqual({}) expect(readImageProvenance(null)).toEqual({}) expect(readImageProvenance('{"identityAnchorImageId":"anchor","referenceImageId":7}')).toMatchObject({ identityAnchorImageId: 'anchor', referenceImageId: undefined }) }) it('选角就绪结果可以补入尚无形态的角色目录', () => { const rows = mergeCastingSubjects( groupIdentitySubjects([formFixture()]), [ { subjectId: 'character-without-form', subjectRef: '@CH0002', subjectName: '陆川', status: 'missing_identity', isLocked: false } ], 'project-1' ) expect(rows).toHaveLength(2) expect(rows.find(item => item.id === 'character-without-form')).toMatchObject({ projectId: 'project-1', module: 'character', forms: [] }) expect(castingStatusLabel('candidate_pending')).toBe('等待确认演员') }) it('辅助视角传递明确参考图、成对尺寸和本次 Prompt,不传形态生图字段', async () => { wrapper = mount(IdentityImageDialog, { attachTo: document.body, props: { open: true, subjectId: 's1', subjectName: '林夏', images: [identityImageFixture()], disabled: false } }) await flushPromises() input('#identity-view', 'three-quarter') input('#identity-reference', 'identity-image-1') input('#identity-width', '2048') document.querySelector('#identity-image-cost')!.click() await flushPromises() expect(button('确认生成身份图').disabled).toBe(true) input('#identity-height', '2048') input('#identity-image-prompt', ' 自定义身份提示词 ') await flushPromises() button('确认生成身份图').click() await flushPromises() expect(wrapper.emitted('generate')).toEqual([ [ { viewType: 'three-quarter', referenceImageId: 'identity-image-1', width: 2048, height: 2048, prompt: '自定义身份提示词' } ] ]) }) it('切换主体清空生图配置和费用确认,失效参考图不能提交', async () => { wrapper = mount(IdentityImageDialog, { attachTo: document.body, props: { open: true, subjectId: 's1', subjectName: '林夏', images: [identityImageFixture()], disabled: false } }) await flushPromises() input('#identity-reference', 'identity-image-1') document.querySelector('#identity-image-cost')!.click() await flushPromises() await wrapper.setProps({ images: [] }) expect(button('确认生成身份图').disabled).toBe(true) await wrapper.setProps({ subjectId: 's2', subjectName: '陆川' }) expect(selectControl(wrapper!, 'id', 'identity-reference').props('value')).toBe('') expect(document.querySelector('#identity-image-cost')!.getAttribute('aria-checked')).toBe('false') }) it('Character 普通身份图入口只提供辅助视角,primary 必须走选角候选接口', async () => { wrapper = mount(IdentityImageDialog, { attachTo: document.body, props: { open: true, subjectId: 's1', subjectName: '林夏', module: 'character', images: [identityImageFixture()], disabled: false } }) await flushPromises() const select = selectControl(wrapper!, 'id', 'identity-view') expect(select.props('options')!.map(item => item.value)).toEqual(['front', 'three-quarter', 'full-body']) expect(select.props('value')).toBe('front') }) it('辅助图不能切换母版,提示词按纯文本展示', async () => { wrapper = mount(IdentityGallery, { attachTo: document.body, props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [identityImageFixture({ viewType: 'front', isAnchor: false })] } }) expect(button('确认选角并锁定').disabled).toBe(true) await expandSections() expect(wrapper.text()).toContain('') expect(wrapper.find('script').exists()).toBe(false) expect(wrapper.emitted('anchor')).toBeUndefined() }) it('角色已有母版但未锁定时仍可确认选角,确认文案不冒充普通母版切换', async () => { wrapper = mount(IdentityGallery, { attachTo: document.body, props: { subjectName: '林夏', module: 'character', identityLocked: false, disabled: false, images: [identityImageFixture({ isAnchor: true })] } }) button('确认选角并锁定').click() await flushPromises() expect(document.body.textContent).toContain('同一事务中切换身份母版并锁定 Identity') button('确认演员选择').click() expect(wrapper.emitted('anchor')).toEqual([['identity-image-1']]) }) it('选角候选不再发送 Provider,不携带普通身份图视角字段', async () => { wrapper = mount(CastingCandidateDialog, { attachTo: document.body, props: { open: true, subjectId: 'subject-1', subjectName: '林夏', disabled: false } }) await flushPromises() expect(document.body.textContent).toContain('不会复用当前身份母版') document.querySelector('#casting-candidate-cost')!.click() await flushPromises() button('确认生成候选').click() expect(wrapper.emitted('generate')).toEqual([[{}]]) }) it('角色选角接口区分批量身份、候选生图与确认事务', async () => { const fetcher = vi.fn().mockImplementation( async () => new Response( JSON.stringify({ data: { total: 0, targetCount: 0, generated: 0, skipped: 0, skippedLocked: 0, failed: 0, failures: [] } }) ) ) vi.stubGlobal('fetch', fetcher) await subjectIdentityApi.generateCharacters('project/1', { force: false, concurrency: 2 }) await subjectIdentityApi.generateCastingCandidate('subject/1', {}) await subjectIdentityApi.confirmCasting('subject/1', 'image/1') expect(fetcher.mock.calls.map(([url]) => url)).toEqual([ '/api/projects/project%2F1/character-identities/generate', '/api/subjects/subject%2F1/identity/casting-candidates', '/api/subjects/subject%2F1/identity/images/image%2F1/casting' ]) expect(fetcher.mock.calls.map(([, init]) => init?.method)).toEqual(['POST', 'POST', 'PUT']) }) })