Files
short-drama-agent-front/src/features/subject-images/images.test.ts
T

218 lines
11 KiB
TypeScript

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 '../workflows/operations'
import GenerateImageDialog from './components/GenerateImageDialog.vue'
import ImageGalleryDialog from './components/ImageGalleryDialog.vue'
import { coverImage, hasRunningImages, primaryImage, validImageSize } from './model'
import { formFixture, imageFixture } from './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.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(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')
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(
module === 'prop' ? '道具形态生图暂不自动引用' : '后端会自动引用此人物/场景当前的身份母版'
)
})
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('生图确认发送 Seedream 与正式形态 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', { provider: 'seedream', 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', { provider: 'seedream', setPrimary: true }]])
})
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
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 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)
})
})