feat: 同步精简接口并统一表单校验
This commit is contained in:
@@ -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: ['保留硬约束'] }]
|
||||
])
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -386,9 +386,7 @@ describe('管理后台组件边界', () => {
|
||||
updatedAt: '',
|
||||
episodes: [],
|
||||
characters: [],
|
||||
world: null,
|
||||
reviews: [],
|
||||
tasks: []
|
||||
world: null
|
||||
})
|
||||
vi.spyOn(projectsApi, 'checkpoints').mockResolvedValue([])
|
||||
const paths = [
|
||||
|
||||
Reference in New Issue
Block a user