import { defineComponent } from 'vue' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import { projectContextKey } from '@/features/projects/context' import { testProjectContext } from '@/testing/project-context' import { getOperation } from '@/features/workflows/operations' import { formFixture, imageFixture } from '@/features/subject-images/testing/fixtures' import { currentIdentityAnchorId, getImageSession } from '@/features/subject-images/model' import { useSubjectImages } from '@/features/subject-images/useSubjectImages' import FormPromptDialog from '@/features/subject-images/components/FormPromptDialog.vue' import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue' let wrapper: VueWrapper | undefined const projectId = 'capability-test' /** 从实际挂载的确认弹窗查找操作按钮。 */ function find(label: string) { return [...document.querySelectorAll('button')].find(item => item.textContent?.trim() === label)! } afterEach(() => { wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' vi.unstubAllGlobals() Object.assign(getOperation(projectId), { pending: false, error: '', notice: '', label: '' }) Object.assign(getImageSession(projectId), { receipt: null, promptReceipt: null }) }) async function setup() { let service!: ReturnType const context = testProjectContext() const form = formFixture(projectId) const fetcher = vi.fn().mockImplementation(async (url, init) => { const path = String(url) let data: unknown = [form] if (init?.method === 'POST') { if (path.endsWith('/generation-prompts')) data = { total: 2, targetCount: 2, generated: 1, skipped: 0, failed: 1, failures: [{ subjectFormId: 'form-failed', error: '模型拒绝' }] } else if (path.endsWith('/generation-prompt')) { form.generationPrompt = '正式提示词' data = { id: form.id, subjectId: form.subjectId, generationPrompt: form.generationPrompt } } else if (path.includes('/subject-forms/')) data = imageFixture() else data = { total: 3, targetCount: 1, generated: 1, skipped: 0, failed: 0, failures: [], eligibleCount: 3, remaining: 2 } } return new Response(JSON.stringify({ data })) }) vi.stubGlobal('fetch', fetcher) wrapper = mount( defineComponent({ setup() { service = useSubjectImages() return () => null } }), { global: { provide: { [projectContextKey as symbol]: context } } } ) await flushPromises() return { service, context, form, fetcher, posts: () => fetcher.mock.calls.filter(([, init]) => init?.method === 'POST') } } describe('形态正式提示词与批量配置', () => { it('单个使用正式形态 ID 和 force,生成提示词不会调用图片接口', async () => { const { service, posts } = await setup() await service.generatePrompt('unknown-form', false) expect(posts()).toHaveLength(0) await service.generatePrompt('form-db-1', false) expect(posts()).toHaveLength(1) expect(posts()[0]?.[0]).toBe('/api/subject-forms/form-db-1/generation-prompt') expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ force: false }) expect(service.forms.value[0]?.generationPrompt).toBe('正式提示词') await service.generatePrompt('form-db-1', true) expect(JSON.parse(String(posts()[1]?.[1]?.body))).toEqual({ force: true }) }) it('提示词接口返回空正文时显示失败,不误报保存成功', async () => { const { service, fetcher } = await setup() fetcher.mockResolvedValueOnce( new Response(JSON.stringify({ data: { id: 'form-db-1', subjectId: 'subject-db-1', generationPrompt: '' } })) ) await service.generatePrompt('form-db-1', false) expect(getOperation(projectId).error).toContain('未确认正式提示词已保存') }) it('批量提示词保留部分失败回执,不覆盖图片回执且不传图片上限', async () => { const { service, posts } = await setup() service.limit.value = 1 service.promptConcurrency.value = 4 service.promptForce.value = true await service.generatePrompts() expect(posts()[0]?.[0]).toBe('/api/projects/capability-test/subject-forms/generation-prompts') expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ concurrency: 4, force: true }) expect(service.session.value.promptReceipt?.result.failures[0]?.error).toBe('模型拒绝') expect(service.session.value.receipt).toBeNull() }) it('批量提示词和图片可按主体模块缩小范围', async () => { const { service, posts } = await setup() service.batchModule.value = 'scene' await service.generatePrompts() await service.generateProject() expect(JSON.parse(String(posts()[0]?.[1]?.body))).toMatchObject({ module: 'scene' }) expect(JSON.parse(String(posts()[1]?.[1]?.body))).toMatchObject({ module: 'scene' }) }) it('图片数量上限可选,非法上限和并发阻止提交', async () => { const { service, posts } = await setup() for (const limit of [0, -1, 1.5]) { service.limit.value = limit await service.generateProject() } expect(posts()).toHaveLength(0) service.limit.value = 1 await service.generateProject() expect(JSON.parse(String(posts()[0]?.[1]?.body))).toEqual({ concurrency: 2, force: false, limit: 1 }) expect(service.session.value.receipt?.result.remaining).toBe(2) service.limit.value = '' await service.generateProject() expect(JSON.parse(String(posts()[1]?.[1]?.body))).not.toHaveProperty('limit') service.promptConcurrency.value = 0 await service.generatePrompts() expect(posts()).toHaveLength(2) }) it.each(['draft', 'generating', 'need_review', 'failed'] as const)('%s 剧本不能生成提示词和图片', async status => { const { service, context, posts } = await setup() context.data.value!.project.status = status await service.generatePrompt('form-db-1', true) await service.generatePrompts() await service.generateProject() expect(posts()).toHaveLength(0) }) it.each(['character', 'scene', 'prop'])('%s 使用明确母版 ID,不以缺少追溯信息触发生图', async module => { const { service, form, posts } = await setup() form.subject.module = module form.subject.identity = { id: 'identity', isLocked: false, anchorImageId: 'anchor-new' } expect(currentIdentityAnchorId(form)).toBeUndefined() form.subject.identity.isLocked = true expect(currentIdentityAnchorId(form)).toBe('anchor-new') await service.query.refresh() expect(posts()).toHaveLength(0) }) it('正式提示词确认必须勾选,未确认不发送事件', async () => { const form = formFixture() wrapper = mount(FormPromptDialog, { attachTo: document.body, props: { open: true, disabled: false, form } }) await flushPromises() find('生成正式提示词').click() await flushPromises() expect(find('确认生成正式提示词').disabled).toBe(true) expect(wrapper.emitted('generate')).toBeUndefined() document.querySelector('.app-dialog [role="checkbox"]')!.click() await flushPromises() find('确认生成正式提示词').click() await flushPromises() expect(wrapper.emitted('generate')).toEqual([['form-db-1', false]]) }) it('缺少正式和原始提示词仍可确认生图,由后端补齐而非前端编造', async () => { const form = formFixture() form.appearancePrompt = null form.generationPrompt = null wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, disabled: false, form } }) await flushPromises() document.querySelector('#confirm-image-cost')!.click() await flushPromises() const submit = [...document.querySelectorAll('button')].find( item => item.textContent?.trim() === '确认生成图片' )! expect(submit.disabled).toBe(false) submit.click() await flushPromises() expect(wrapper.emitted('generate')).toEqual([['form-db-1', { setPrimary: false }]]) }) })