224 lines
11 KiB
TypeScript
224 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.useRealTimers()
|
|
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(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
|
|
expect([...history.querySelectorAll('img')].every(image => image.style.objectFit === 'cover')).toBe(true)
|
|
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')
|
|
expect(document.querySelector<HTMLImageElement>('.asset-image-preview img')?.style.objectFit).toBe('contain')
|
|
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('母版')
|
|
expect(document.body.textContent).not.toContain('道具形态生图暂不自动引用')
|
|
})
|
|
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('生图确认发送正式形态 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', { 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', { setPrimary: true }]])
|
|
})
|
|
|
|
it('图库不自动生图,主图切换经确认后 PUT,失败图片不能设主图', async () => {
|
|
vi.useFakeTimers()
|
|
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 vi.advanceTimersByTimeAsync(30_000)
|
|
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)
|
|
})
|
|
})
|