feat: 同步精简接口并统一表单校验

This commit is contained in:
GJ
2026-09-10 22:34:47 +08:00
parent 82d7bde631
commit 2eea0ea72e
66 changed files with 1611 additions and 1100 deletions
+1
View File
@@ -56,6 +56,7 @@ describe('精简入口保留功能', () => {
await flushPromises()
await wrapper.get('.n-collapse-item__header-main').trigger('click')
await wrapper.get('form').trigger('submit')
await flushPromises()
expect(wrapper.emitted('save')).toMatchObject([
[{ characterPrompt: '人物风格', hardConstraints: ['保留硬约束'] }]
])
+165
View File
@@ -0,0 +1,165 @@
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<HTMLButtonElement>('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<HTMLInputElement | HTMLTextAreaElement>(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<typeof fetch>()
.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()
button('分类风格与硬约束').click()
await flushPromises()
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: { 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('异步验证期间切换对象或重复提交不会执行旧动作', 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<void>(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()
})
})
+1 -3
View File
@@ -386,9 +386,7 @@ describe('管理后台组件边界', () => {
updatedAt: '',
episodes: [],
characters: [],
world: null,
reviews: [],
tasks: []
world: null
})
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
const paths = [