You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

489 lines
17 KiB

import axios from 'axios'
import Attachments from '@/views/survey/components/SurveyAttachments.vue'
import { saveAs } from 'file-saver'
jest.mock('file-saver', () => ({ saveAs: jest.fn() }))
import { mount, shallowMount, createLocalVue } from '@vue/test-utils'
import ElementUI from 'element-ui'
import FieldSettings from '@/views/survey/components/SurveyFieldSettings.vue'
import Editor from '@/views/survey/components/editPane.vue'
import Question from '@/views/survey/components/SurveyQuestion.vue'
import PreviewField from '@/views/survey/components/SurveyPreviewField.vue'
import Description from '@/views/survey/components/SurveyDescription.vue'
import Fill from '@/views/survey/components/SurveyFillDialog.vue'
import Wizard from '@/views/survey/components/SurveyCreateWizard.vue'
import Results from '@/views/survey/components/SurveyResultsDialog.vue'
import { save, index as questionIndex } from '@/api/survey/index'
import { statistics, show } from '@/api/survey/evaluationForm'
jest.mock('@/api/survey/index', () => ({
save: jest.fn(() => Promise.resolve({ id: 1 })),
index: jest.fn()
}))
jest.mock('@/api/survey/evaluationForm', () => ({
statistics: jest.fn(),
index: jest.fn(),
show: jest.fn(),
textAnswers: jest.fn()
}))
const localVue = createLocalVue()
localVue.use(ElementUI)
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('Survey editing and results', () => {
afterEach(() => {
jest.clearAllMocks()
})
it('keeps unrestricted and multiple audience courses mutually exclusive', () => {
const state = { form: { audience_course_ids: [] }}
Object.defineProperty(state, 'audienceSelection', {
get: () => Wizard.computed.audienceSelection.call(state)
})
expect(state.audienceSelection).toEqual(['all'])
Wizard.methods.changeAudience.call(state, ['all', 11])
expect(state.form.audience_course_ids).toEqual([11])
Wizard.methods.changeAudience.call(state, [11, 12])
expect(state.form.audience_course_ids).toEqual([11, 12])
Wizard.methods.changeAudience.call(state, [11, 12, 'all'])
expect(state.form.audience_course_ids).toEqual([])
Wizard.methods.changeAudience.call(state, [])
expect(state.audienceSelection).toEqual(['all'])
})
it('saves mixed dimensions with zero lower bound and stable option keys', async() => {
const wrapper = shallowMount(Editor, {
localVue,
propsData: {
question: {
name: '资源对接',
field: 'resource',
edit_input: 'multi_dimension',
dimensions: [
{
name: '是否跟进',
field: 'follow',
select_item: [{ key: 'yes', value: '是' }],
need_fill: true
},
{
name: '意向数量',
field: 'count',
edit_input: 'number',
config: { integer: true, min: 0 },
need_fill: true
}
]
}
}
})
await wrapper.vm.save()
const payload = save.mock.calls[0][0]
expect(payload.dimensions[0].select_item[0].key).toBe('yes')
expect(payload.dimensions[1].config.min).toBe(0)
expect(payload.dimensions[1].edit_input).toBe('number')
expect(payload.dimensions.every((d) => !d.need_fill && d.rule === '')).toBe(
true
)
expect(wrapper.emitted().saved).toBeTruthy()
wrapper.destroy()
})
it('saves the selected personal information field for automatic filling', async() => {
const wrapper = shallowMount(Editor, {
localVue,
propsData: {
question: {
name: '姓名',
field: 'student_name',
edit_input: 'text',
config: { user_field: 'name' }
}
}
})
expect(wrapper.vm.draft.config.user_field).toBe('name')
wrapper.vm.draft.config.user_field = 'company_name'
await wrapper.vm.save()
expect(save.mock.calls[0][0].config.user_field).toBe('company_name')
expect(wrapper.vm.userFields).not.toHaveProperty('password')
wrapper.destroy()
})
it('previews conditional questions and per-option notes with a selection limit', async() => {
questionIndex.mockResolvedValue({
data: [
{
id: 1,
field: 'insurance',
name: '保险',
edit_input: 'radio',
config: {},
select_item: [{ key: 'yes', value: '需要' }]
},
{
id: 2,
field: 'idcard',
name: '身份证',
edit_input: 'text',
config: { visible_when: { field: 'insurance', value: 'yes' }}
}
]
})
const state = { questions: (await questionIndex()).data, answers: {}}
expect(Fill.computed.shownQuestions.call(state)).toHaveLength(1)
state.answers.insurance = 'yes'
expect(Fill.computed.shownQuestions.call(state)).toHaveLength(2)
state.answers.insurance = 'no'
expect(Fill.computed.shownQuestions.call(state)).toHaveLength(1)
const warning = jest.fn()
const choice = mount(Question, {
localVue,
mocks: { $message: { warning }},
propsData: {
question: {
name: '会场',
edit_input: 'checkbox',
config: {
max_choices: 2,
option_notes: { tax: { placeholder: '咨询问题' }}
},
select_item: [
{ key: 'tax', value: '财税' },
{ key: 'legal', value: '法律' },
{ key: 'ip', value: '知识产权' }
]
}
}
})
choice.vm.setPreviewValue(['tax'])
await choice.vm.$nextTick()
expect(choice.find('textarea[placeholder="咨询问题"]').exists()).toBe(true)
choice.vm.setPreviewValue(['tax', 'legal', 'ip'])
expect(warning).toHaveBeenCalled()
expect(choice.vm.previewChecks).toEqual(['tax'])
choice.destroy()
})
it('edits conditions using only earlier choice questions and clears stale options', async() => {
const parent = {
id: 1,
field: 'insurance',
name: '保险',
edit_input: 'radio',
select_item: [{ key: 'yes', value: '需要' }]
}
const child = {
id: 2,
field: 'idcard',
name: '身份证',
edit_input: 'text',
config: { visible_when: { field: 'insurance', value: 'yes' }}
}
const warning = jest.fn()
const wrapper = shallowMount(Editor, {
localVue,
mocks: { $message: { warning, success: jest.fn() }},
propsData: {
question: child,
questions: [parent, child, { ...parent, id: 3, field: 'later' }]
}
})
expect(wrapper.vm.conditionQuestions.map((q) => q.field)).toEqual([
'insurance'
])
expect(wrapper.vm.draft.config.visible_when.value).toBe('yes')
await wrapper.vm.save()
expect(save.mock.calls[0][0].config.visible_when).toEqual({
field: 'insurance',
value: 'yes'
})
save.mockClear()
wrapper.vm.changeConditionQuestion('insurance')
await wrapper.vm.save()
expect(warning).toHaveBeenCalled()
expect(save).not.toHaveBeenCalled()
wrapper.vm.toggleCondition(false)
await wrapper.vm.save()
expect(save.mock.calls[0][0].config.visible_when).toBeUndefined()
wrapper.destroy()
})
it('hides dependent chains even when hidden parents retain an old answer', () => {
const state = {
questions: [
{ field: 'first', config: {}},
{
field: 'second',
config: { visible_when: { field: 'first', value: 'yes' }}
},
{
field: 'third',
config: { visible_when: { field: 'second', value: 'yes' }}
}
],
answers: { first: 'no', second: 'yes' }
}
expect(Fill.computed.shownQuestions.call(state)).toHaveLength(1)
state.answers.first = 'yes'
expect(Fill.computed.shownQuestions.call(state)).toHaveLength(3)
})
it('keeps a cleared numeric maximum unlimited instead of defaulting to zero', () => {
const field = {
edit_input: 'number',
config: { integer: true, min: 0, max: undefined }
}
const wrapper = mount(
{
render(h) {
return h('el-form', [h(FieldSettings, { props: { field }})])
}
},
{ localVue }
)
expect(field.config.max).toBeUndefined()
expect(wrapper.findAll('input[type="number"]').at(1).element.value).toBe(
''
)
wrapper.destroy()
})
it('renders zero as an answer and chapter headings without inputs', () => {
const wrapper = mount(Question, {
localVue,
propsData: {
answered: true,
question: { name: '数量', edit_input: 'number', display_value: '0' }
}
})
expect(wrapper.find('.answer').text()).toBe('0')
wrapper.destroy()
const section = mount(Question, {
localVue,
propsData: {
question: {
name: '课程评价',
edit_input: 'section',
help: '请选择评分'
}
}
})
expect(section.findAll('input').length).toBe(0)
expect(section.text()).toContain('请选择评分')
section.destroy()
})
it('shows score labels once and reveals selected supplementary input', async() => {
const wrapper = mount(Question, {
localVue,
propsData: {
question: {
name: '评分',
edit_input: 'radio',
config: { is_score: true, other_key: 'custom' },
select_item: [{ key: 'custom', value: '10分', score: 10 }]
}
}
})
expect(wrapper.text().match(/10分/g)).toHaveLength(1)
wrapper.setData({ previewValue: 'custom' })
await wrapper.vm.$nextTick()
expect(
wrapper.find('textarea[placeholder="请填写补充说明"]').exists()
).toBe(true)
wrapper.destroy()
})
it('shows all reply options with selected radio and checkbox values read-only', () => {
for (const type of ['radio', 'checkbox']) {
const wrapper = mount(Question, {
localVue,
propsData: {
answered: true,
question: {
name: '选择',
edit_input: type,
selected_keys: ['0'],
display_value: '零分',
select_item: [
{ key: 0, value: '零分' },
{ key: 'other', value: '其他' }
]
}
}
})
expect(wrapper.findAll('input')).toHaveLength(2)
expect(wrapper.findAll('input').at(0).element.checked).toBe(true)
expect(wrapper.findAll('input').at(1).element.checked).toBe(false)
expect(
wrapper
.findAll('input')
.wrappers.every((input) => input.element.disabled)
).toBe(true)
expect(wrapper.text()).toContain('学员选择:零分')
expect(wrapper.text()).toContain('其他')
wrapper.destroy()
}
const combo = mount(Question, {
localVue,
propsData: {
answered: true,
question: {
name: '组合',
edit_input: 'multi_dimension',
dimensions: [
{
field: 'intent',
name: '意向',
selected_keys: ['no'],
display_value: '否',
select_item: [
{ key: 'yes', value: '是' },
{ key: 'no', value: '否' }
]
}
]
}
}
})
expect(combo.findAll('input')).toHaveLength(2)
expect(combo.findAll('input').at(1).element.checked).toBe(true)
combo.destroy()
})
it('uses wrapping scores and vertical ordinary choices for preview fields', () => {
const score = mount(PreviewField, {
localVue,
propsData: {
field: {
edit_input: 'radio',
config: { is_score: true },
select_item: [{ key: '5', value: '5分' }]
}
}
})
expect(score.find('.scores').exists()).toBe(true)
score.destroy()
const ordinary = mount(PreviewField, {
localVue,
propsData: {
field: {
edit_input: 'radio',
config: {},
select_item: [{ key: 'yes', value: '是' }]
}
}
})
expect(ordinary.find('.scores').exists()).toBe(false)
ordinary.destroy()
const number = mount(PreviewField, {
localVue,
propsData: {
field: { edit_input: 'number', config: { integer: true, min: 0 }}
}
})
expect(number.find('input').attributes('placeholder')).toBe('请输入数量')
expect(number.text()).not.toContain('最小')
number.destroy()
})
it('renders rich descriptions safely and preserves plain text', () => {
const rich = mount(Description, {
propsData: {
value:
'<p style="text-indent:2em"><strong>说明</strong><img src="/a.png" onerror="alert(1)"><script>alert(1)</script></p>'
}
})
expect(rich.find('strong').text()).toBe('说明')
expect(rich.find('p').element.style.textIndent).toBe('2em')
expect(rich.find('script').exists()).toBe(false)
expect(rich.find('img').attributes('onerror')).toBeUndefined()
rich.destroy()
const plain = mount(Description, {
propsData: { value: '第一行\n第二行' }
})
expect(plain.text()).toContain('第一行\n第二行')
plain.destroy()
})
it('loads real statistics and the server reply snapshot', async() => {
statistics.mockResolvedValue({
responses: 2,
questions_count: 1,
average: 4,
questions: [
{
id: 1,
name: '满意度',
edit_input: 'radio',
stats: {
valid_count: 2,
average: 4,
other_count: 0,
options: [{ label: '满意', count: 2, percentage: 100 }]
}
}
]
})
show.mockResolvedValue({
id: 7,
snapshot: {
title: '提交时的标题',
desc: '<p><strong>历史问卷说明</strong></p>'
},
user: { name: '学员' },
questions: [
{ id: 1, name: '历史题目', edit_input: 'number', display_value: '0' }
]
})
const wrapper = mount(Results, {
localVue,
propsData: {
visible: false,
surveyData: { id: 10, title: '现在的标题' }
}
})
wrapper.setProps({ visible: true })
await flush()
expect(statistics).toHaveBeenCalledWith({
course_content_evaluation_id: 10
})
expect(wrapper.text()).toContain('有效作答 2 人')
await wrapper.vm.openReply({ id: 7 })
await flush()
expect(wrapper.vm.reply.snapshot.title).toBe('提交时的标题')
expect(
document.body.querySelector('.reply .description strong').textContent
).toBe('历史问卷说明')
expect(wrapper.text()).not.toContain('<strong>')
expect(wrapper.vm.reply.questions[0].display_value).toBe('0')
wrapper.destroy()
})
it('shows file names and downloads with original names', async() => {
const file = { id: 81, original_name: '材料.pdf', url: '/storage/files/a.pdf', size: 2048 }
const answer = mount(Question, { localVue, propsData: { answered: true, question: { edit_input: 'file', name: '附件', value: [file] } } })
expect(answer.text()).toContain('材料.pdf')
expect(answer.text()).toContain('下载')
const blob = new Blob(['example'])
const get = jest.spyOn(axios, 'get').mockResolvedValue({ headers: { 'content-type': 'application/pdf' }, data: blob })
const oldFetch = global.fetch
global.fetch = jest.fn().mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
await answer.find(Attachments).vm.download(file)
expect(saveAs).toHaveBeenCalledWith(blob, '材料.pdf')
expect(get).toHaveBeenCalledWith('/api/admin/course-content-evaluation-form/show', expect.objectContaining({ params: expect.objectContaining({ download_file_id: 81 }) }))
get.mockRestore()
global.fetch = oldFetch
answer.destroy()
})
it('edits allowed upload formats and shows them in preview', async() => {
const field = { edit_input: 'file', config: { max_files: 2, allowed_extensions: ['pdf'] } }
const wrapper = shallowMount(FieldSettings, { localVue, propsData: { field } })
wrapper.find('el-select-stub').vm.$emit('change', ['pdf', 'docx'])
await wrapper.vm.$nextTick()
expect(field.config.allowed_extensions).toEqual(['pdf', 'docx'])
const preview = mount(PreviewField, { localVue, propsData: { field } })
expect(preview.text()).toContain('PDF、DOCX')
wrapper.destroy()
preview.destroy()
})
it('configures per-option notes, placeholders and selection limits without a new type', async() => {
const field = { edit_input: 'checkbox', select_item: [{ key: 'tax', value: '财税' }, { key: 'legal', value: '法律' }], config: { max_choices: 1, other_key: 'tax', other_placeholder: '补充说明提示' } }
const settings = shallowMount(FieldSettings, { localVue, propsData: { field } })
settings.vm.toggleNote('tax', true)
expect(field.config.other_key).toBe('')
field.config.option_notes.tax.placeholder = '可初步填写您想咨询的问题'
field.config.option_notes.tax.required = true
const editor = shallowMount(Editor, { localVue, propsData: { question: { ...field, name: '会场', field: 'venues' } } })
await editor.vm.save()
expect(save.mock.calls[0][0].config).toMatchObject({ max_choices: 1, other_placeholder: '补充说明提示', option_notes: { tax: { placeholder: '可初步填写您想咨询的问题', required: true } } })
settings.vm.removeOption(0)
expect(field.config.option_notes.tax).toBeUndefined()
settings.destroy()
editor.destroy()
})
})