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().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('[aria-label="图片历史"]')! expect(history.querySelectorAll('.image-history-item')).toHaveLength(3) expect(document.querySelector('.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('[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('.asset-image-preview img')?.style.objectFit).toBe('contain') history.querySelector('[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('#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('#image-width')! width.value = '2048' width.dispatchEvent(new Event('input', { bubbles: true })) document.querySelector('#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('#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().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('') 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('[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() .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) }) })