166 lines
7.7 KiB
TypeScript
166 lines
7.7 KiB
TypeScript
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()
|
|
})
|
|
})
|