411 lines
19 KiB
TypeScript
411 lines
19 KiB
TypeScript
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
|
|
} 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<HTMLInputElement | HTMLSelectElement>(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<HTMLImageElement>('.n-image-preview')!
|
|
expect(original.getAttribute('src')).toBe(main.attributes('src'))
|
|
expect(original.style.objectFit).not.toBe('cover')
|
|
expect(wrapper.emitted('anchor')).toBeUndefined()
|
|
})
|
|
|
|
it('身份图库使用精简资产,不展示已移除的提示词和启停状态', () => {
|
|
wrapper = mount(IdentityGallery, {
|
|
props: {
|
|
subjectName: '林夏',
|
|
module: 'character',
|
|
identityLocked: true,
|
|
disabled: false,
|
|
images: [identityImageFixture()]
|
|
}
|
|
})
|
|
expect(wrapper.text()).toContain('当前身份母版')
|
|
expect(wrapper.text()).not.toContain('本次实际提示词')
|
|
expect(wrapper.text()).not.toContain('停用')
|
|
})
|
|
|
|
it('全部历史记录显示在预览下方,候选、辅助、失败和进行中图片均可切换查看', async () => {
|
|
const images = [
|
|
identityImageFixture({
|
|
id: 'candidate',
|
|
imageUrl: '/storage/candidate.png',
|
|
isAnchor: false
|
|
}),
|
|
identityImageFixture({
|
|
id: 'failed',
|
|
status: 'failed',
|
|
imageUrl: null,
|
|
isAnchor: 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 }),
|
|
identityImageFixture({
|
|
id: 'generating',
|
|
status: 'generating',
|
|
imageUrl: null,
|
|
isAnchor: 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<HTMLElement>('.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 }))
|
|
}
|
|
})
|
|
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', 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', () => {
|
|
expect(() => groupIdentitySubjects([{ ...formFixture(), subjectId: 'wrong' }])).toThrow('不匹配')
|
|
})
|
|
|
|
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<HTMLInputElement>('#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<HTMLInputElement>('#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()).not.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<HTMLInputElement>('#casting-candidate-cost')!.click()
|
|
await flushPromises()
|
|
button('确认生成候选').click()
|
|
await flushPromises()
|
|
expect(wrapper.emitted('generate')).toEqual([[{}]])
|
|
})
|
|
|
|
it('角色选角接口区分批量身份、候选生图与确认事务', async () => {
|
|
const fetcher = vi.fn<typeof fetch>().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'])
|
|
})
|
|
})
|