import { defineComponent, h, reactive } from 'vue' import { flushPromises, mount, type VueWrapper } from '@vue/test-utils' import { afterEach, describe, expect, it, vi } from 'vitest' import { NFormItem, NInput } from 'naive-ui' import AppForm from '@/components/ui/AppForm.vue' import CreateProjectDialog from '@/features/projects/components/CreateProjectDialog.vue' import GenerateImageDialog from '@/features/subject-images/components/GenerateImageDialog.vue' import StyleEditor from '@/features/visual-style/components/StyleEditor.vue' import StyleImages from '@/features/visual-style/components/StyleImages.vue' import { formFixture } from '@/features/subject-images/testing/fixtures' import { requiredTextRule } from '@/lib/form-rules' let wrapper: VueWrapper | undefined afterEach(() => { wrapper?.unmount() wrapper = undefined document.body.innerHTML = '' vi.restoreAllMocks() vi.unstubAllGlobals() }) function button(text: string) { const result = [...document.querySelectorAll('button')].find( el => el.textContent?.trim() === text ) if (!result) throw new Error(`找不到按钮:${text}`) return result } async function input(selector: string, value: string) { const el = document.querySelector(selector)! el.value = value el.dispatchEvent(new Event('input', { bubbles: true })) await flushPromises() } function feedback() { return [...document.querySelectorAll('.n-form-item-feedback--error')].map(el => el.textContent).join(' ') } async function submit() { document.querySelector('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })) await flushPromises() } describe('统一的 Naive UI 表单校验', () => { it('新建剧本使用字段反馈拦截空白主题和小数集数,修正后只发送一次请求', async () => { const fetcher = vi .fn() .mockResolvedValue(new Response('{"projectId":"created","status":"generating"}', { status: 202 })) vi.stubGlobal('fetch', fetcher) wrapper = mount(CreateProjectDialog, { attachTo: document.body }) button('新建剧本').click() await flushPromises() expect(document.querySelector('form')!.noValidate).toBe(true) expect(document.querySelector('[required]')).toBeNull() await input('#topic', ' ') const focus = vi.spyOn(HTMLTextAreaElement.prototype, 'focus') await submit() expect(feedback()).toContain('请填写故事主题') // 无布局的 DOM 环境会被模态框焦点圈带回哨兵,检查实际聚焦目标。 expect(focus.mock.contexts).toContain(document.querySelector('#topic')) expect(fetcher).not.toHaveBeenCalled() await input('#topic', ' 雨夜来信 ') await input('#episode-count', '1.5') await submit() expect(feedback()).toContain('计划集数须为正整数') expect(fetcher).not.toHaveBeenCalled() await input('#episode-count', '3') expect(feedback()).toBe('') button('开始生成').click() button('开始生成').click() await flushPromises() expect(fetcher).toHaveBeenCalledTimes(1) expect(wrapper.emitted('created')).toEqual([['created']]) }) it('图片宽高错误显示在两侧字段,补全后同时消失,切换目标清除校验状态', async () => { wrapper = mount(GenerateImageDialog, { attachTo: document.body, props: { open: true, form: formFixture(), disabled: false } }) await flushPromises() await input('#image-width', '1024') expect(document.querySelectorAll('.n-form-item-feedback--error')).toHaveLength(2) expect(feedback()).toContain('宽高须同时填写正整数') await input('#image-height', '768') expect(feedback()).toBe('') await input('#image-height', '') expect(feedback()).not.toBe('') await wrapper.setProps({ form: formFixture('other') }) await flushPromises() expect(feedback()).toBe('') expect(wrapper.emitted('generate')).toBeUndefined() }) it('折叠后的非法 JSON 仍拦截保存并展开错误,修正后保留草稿正常提交', async () => { wrapper = mount(StyleEditor, { attachTo: document.body, props: { visualStyle: null, disabled: false } }) await flushPromises() expect(button('分类风格与硬约束').getAttribute('aria-expanded')).toBe('true') await input('#style-name', '手工风格') await input('#style-constraints', '{"invalid":true}') button('分类风格与硬约束').click() await flushPromises() await submit() expect(wrapper.emitted('save')).toBeUndefined() expect(feedback()).toContain('请输入 JSON 字符串数组') expect(button('分类风格与硬约束').getAttribute('aria-expanded')).toBe('true') await input('#style-constraints', '["真人写实"]') await submit() expect(wrapper.emitted('save')?.[0]?.[0]).toMatchObject({ name: '手工风格', hardConstraints: ['真人写实'] }) }) it('登记参考图拦截非法地址和小数排序,允许有效存储地址与负整数排序', async () => { wrapper = mount(StyleImages, { attachTo: document.body, props: { projectId: 'form-test-project', images: [], disabled: false } }) await input('[aria-label="风格图片地址"]', 'javascript:alert(1)') await input('[aria-label="风格图片排序"]', '1.5') await submit() expect(feedback()).toContain('请填写有效的 HTTP(S) 或 /storage/ 图片地址') expect(feedback()).toContain('排序须为整数') expect(wrapper.emitted('add')).toBeUndefined() await input('[aria-label="风格图片地址"]', '/storage/style.png') await input('[aria-label="风格图片排序"]', '-1') await submit() expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({ imageUrl: '/storage/style.png', sortOrder: -1 }) }) it('按需读取项目资产并用正式素材 ID 登记风格参考图', async () => { const fetcher = vi.fn().mockResolvedValue( new Response( JSON.stringify({ data: [ { id: 'asset-db-1', projectId: 'form-test-project', name: '人物质感参考', type: 'image', category: 'character', mimeType: 'image/png', extension: 'png', size: 1024, publicUrl: '/storage/reference.png', metadata: null, createdAt: '2026-09-21T00:00:00Z', updatedAt: '2026-09-21T00:00:00Z' } ] }) ) ) vi.stubGlobal('fetch', fetcher) wrapper = mount(StyleImages, { attachTo: document.body, props: { projectId: 'form-test-project', images: [], disabled: false } }) expect(fetcher).not.toHaveBeenCalled() button('从资产库选择').click() await flushPromises() expect(fetcher).toHaveBeenCalledWith( '/api/projects/form-test-project/assets', expect.objectContaining({ method: 'GET' }) ) const option = document.querySelector('.style-asset-option')! expect(option.textContent).toContain('人物质感参考') option.click() await flushPromises() button('登记为整体参考图').click() await flushPromises() expect(wrapper.emitted('add')?.[0]?.[0]).toMatchObject({ projectAssetId: 'asset-db-1', category: 'overall', source: 'upload' }) }) it('异步验证期间切换对象或重复提交不会执行旧动作', async () => { let finish: (() => void) | undefined const model = reactive({ text: '有效输入' }) const onSubmit = vi.fn<() => void>() wrapper = mount( defineComponent({ props: { target: { type: String, default: 'first' } }, setup(props) { return () => h( AppForm, { model, resetKey: props.target, onSubmit, rules: { text: { ...requiredTextRule('文本'), asyncValidator: () => new Promise(resolve => { finish = resolve }) } } }, () => h(NFormItem, { path: 'text', label: '文本' }, () => h(NInput, { value: model.text })) ) } }), { attachTo: document.body } ) const form = document.querySelector('form')! form.dispatchEvent(new Event('submit', { cancelable: true })) form.dispatchEvent(new Event('submit', { cancelable: true })) await flushPromises() await wrapper.setProps({ target: 'second' }) finish!() await flushPromises() expect(onSubmit).not.toHaveBeenCalled() }) })