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: '
说明
历史问卷说明
' }, 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('') 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() }) it('exports all survey responses without the current page filter', async() => { const blob = new Blob(['zip']) const get = jest.spyOn(axios, 'get').mockResolvedValue({ headers: { 'content-type': 'application/zip' }, data: blob }) const state = { exporting: false, surveyData: { id: 10 }, page: 3, $message: { error: jest.fn() } } await Results.methods.exportResponses.call(state) expect(get).toHaveBeenCalledWith('/api/admin/course-content-evaluation-form/index', expect.objectContaining({ params: { export_bundle: 1, course_content_evaluation_id: 10 } })) expect(saveAs).toHaveBeenCalledWith(blob, '问卷答卷_10.zip') expect(state.exporting).toBe(false) get.mockRestore() }) })