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.

665 lines
35 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import fs from 'node:fs'
import assert from 'node:assert/strict'
const source = fs.readFileSync(
new URL('../common/survey.js', import.meta.url),
'utf8'
)
const { required, prepareQuestions, fillUserFields, visibleQuestions, selectedNotes, firstError, progress, submission, showOther, descriptionHtml } =
await import(
'data:text/javascript;base64,' + Buffer.from(source).toString('base64')
)
const questions = prepareQuestions([
{ id: 1, name: '资源', edit_input: 'section' },
{
id: 2,
name: '对接',
edit_input: 'multi_dimension',
need_fill: true,
dimensions: [
{
field: 'intent',
name: '意向',
need_fill: true,
select_item: [{ key: 'no', value: '否' }]
},
{
field: 'count',
name: '数量',
edit_input: 'number',
need_fill: true,
config: { integer: true, min: 0, max: 10 }
}
]
},
{
id: 3,
name: '需求',
edit_input: 'checkbox',
config: { other_key: 'other', other_required: true }
}
])
assert.equal(progress(questions).total, 2)
assert.equal(firstError(questions).id, 2)
questions[1].value.intent = 'no'
assert.equal(progress(questions).answered, 0)
questions[1].value.count = '0'
assert.equal(firstError(questions), null)
assert.equal(progress(questions).answered, 1)
assert.equal(submission(questions)[0].value.count, 0)
questions[1].value.count = '-1'
assert.match(firstError(questions).message, /不能小于/)
questions[1].value.count = '1.5'
assert.match(firstError(questions).message, /整数/)
questions[1].value.count = '11'
assert.match(firstError(questions).message, /不能大于/)
questions[1].value.count = '0'
questions[2].value = ['other']
assert.equal(showOther(questions[2], questions[2].value), true)
assert.equal(firstError(questions).id, 3)
assert.equal(progress(questions).total, 2)
assert.equal(progress(questions).answered, 1)
questions[2].other_text = '对接银行'
assert.equal(firstError(questions), null)
assert.equal(progress(questions).percent, 100)
questions[2].value = []
assert.equal(submission(questions)[1].other_text, '')
assert.equal(submission(questions).length, 2)
questions[1].need_fill = false
questions[1].value = {}
assert.equal(firstError(questions), null)
questions[1].need_fill = true
assert.equal(firstError(questions).id, 2)
assert.equal(showOther({ config: { other_key: '0' } }, ['0']), true)
assert.equal(showOther({ config: {}, allow_input: '0' }, []), false)
console.log('Survey mobile behavior: 20 assertions passed')
// Exercise the real page handlers with Vue 2 reactivity (including supplement visibility).
const { createRequire } = await import('node:module')
const require = createRequire(import.meta.url)
const Vue = require('../../sz-kjsxy-web/node_modules/vue')
const pageSource = fs.readFileSync(new URL('../packages/surveyFill/index.vue', import.meta.url), 'utf8')
const pageScript = pageSource.split('<script>')[1].split('</script>')[0]
.replace(/import[\s\S]*?from\s+['"][^'"]+['"]/g, '')
.replace('export default', 'return')
const page = new Function('SurveyField', 'prepareQuestions', 'required', 'showOther', 'firstError', 'progress', 'submission', 'formatDescription', 'fillUserFields', 'visibleQuestions', 'selectedNotes', pageScript)(
{}, prepareQuestions, () => false, showOther, firstError, progress, submission, descriptionHtml, fillUserFields, visibleQuestions, selectedNotes
)
const vm = new Vue(page)
vm.questionList = prepareQuestions([{ id: 9, name: '资源', edit_input: 'checkbox', config: { other_key: 'other', other_required: true }, select_item: [{ key: 'other', value: '其他' }] }])
vm.setValue(vm.questionList[0], ['other'])
await Vue.nextTick()
assert.equal(vm.questionList[0].show_other, true)
vm.questionList[0].other_text = '补充需求'
assert.equal(submission(vm.questionList)[0].other_text, '补充需求')
vm.setValue(vm.questionList[0], [])
await Vue.nextTick()
assert.equal(vm.questionList[0].show_other, false)
assert.equal(vm.questionList[0].other_text, '')
const fieldSource = fs.readFileSync(new URL('../packages/surveyFill/components/SurveyField.vue', import.meta.url), 'utf8')
const field = new Function('SurveyFile', fieldSource.split('<script>')[1].split('</script>')[0].replace(/import SurveyFile[^\n]*/, '').replace('export default', 'return'))({})
let emitted
field.methods.checkboxChange.call({ $emit: (event, value) => { emitted = value } }, { detail: { value: ['other'] } })
assert.deepEqual(emitted, ['other'])
field.methods.radioChange.call({ $emit: (event, value) => { emitted = value } }, 'ten')
assert.equal(emitted, 'ten')
vm.$destroy()
console.log('Survey page/component events: 6 assertions passed')
const integerInput = new Vue({ ...field, propsData: { field: { edit_input: 'number', config: { integer: true, min: 0 } }, value: '12' } })
assert.equal(integerInput.numberInput({ detail: { value: '12abc' } }), '12')
await Vue.nextTick()
assert.equal(integerInput.numberText, '12')
assert.equal(integerInput.numberInput({ detail: { value: '中文' } }), '12')
assert.equal(integerInput.numberInput({ detail: { value: '1.2' } }), '12')
assert.equal(integerInput.numberInput({ detail: { value: '-2' } }), '12')
assert.equal(integerInput.numberInput({ detail: { value: '0' } }), '0')
assert.equal(integerInput.numberInput({ detail: { value: '' } }), '')
const decimalInput = new Vue({ ...field, propsData: { field: { edit_input: 'number', config: { integer: false } }, value: '' } })
assert.equal(decimalInput.numberInput({ detail: { value: '-2.5' } }), '-2.5')
assert.equal(decimalInput.numberInput({ detail: { value: '1.2.3' } }), '')
integerInput.$destroy()
decimalInput.$destroy()
console.log('Numeric input filtering: 9 assertions passed')
let scrollRequest
const query = {
in() { return this }, select() { return this }, selectViewport() { return this },
boundingClientRect() { return this }, scrollOffset() { return this },
exec(callback) { callback([{ height: 90 }, { top: 450 }, { scrollTop: 200 }]) }
}
globalThis.uni = { createSelectorQuery: () => query, pageScrollTo: options => { scrollRequest = options } }
const navigation = new Vue(page)
navigation.selectChapter(4)
assert.equal(navigation.activeChapter, 4)
assert.equal(scrollRequest.scrollTop, 548)
navigation.$destroy()
delete globalThis.uni
console.log('Chapter navigation: 2 assertions passed')
assert.equal(descriptionHtml('第一行\n第二行'), '第一行<br>第二行')
const html = descriptionHtml('<p style="font-size:36px;font-family:serif;color:red;text-indent:2em"><strong>说明</strong><font size="7" face="serif">内容</font></p>')
assert.equal(html.includes('36px'), false)
assert.equal(html.includes('font-family:serif'), false)
assert.equal(html.includes('size="7"'), false)
assert.equal(html.includes('color:red'), true)
globalThis.uni = { getSystemInfoSync: () => ({ windowWidth: 375, platform: 'devtools' }) }
globalThis.wx = { canIUse: () => false }
const Parser = require('../uview-ui/components/u-parse/libs/MpHtmlParser.js')
const parsed = new Parser(html, { nodes: [] }).parse()
assert.ok(parsed.length > 0)
assert.match(parsed[0].attrs.style, /font-size:14px/)
assert.match(parsed[0].attrs.style, /text-indent:2em/)
assert.ok(pageSource.includes(':html="descriptionHtml"'))
delete globalThis.uni
delete globalThis.wx
console.log('Description rendering and font normalization: 9 assertions passed')
const courseSource = fs.readFileSync(new URL('../packages/mycourse/index.vue', import.meta.url), 'utf8')
assert.ok(!courseSource.includes('问卷调查'))
assert.ok(!courseSource.includes('/packages/surveyFill'))
console.log('Course survey entry removed')
const fullSurvey = prepareQuestions([
...Array.from({ length: 6 }, (_, i) => ({ id: 'section' + i, edit_input: 'section' })),
...Array.from({ length: 28 }, (_, i) => ({ id: i, edit_input: 'multi_dimension',
dimensions: Array.from({ length: i < 8 ? 3 : 2 }, (_, j) => ({ field: 'd' + j, edit_input: 'number' })) }))
])
assert.equal(fullSurvey.filter(q => q.edit_input !== 'section').reduce((sum, q) => sum + q.dimensions.length, 0), 64)
assert.equal(progress(fullSurvey).total, 28)
console.log('Question count uses parent questions, excluding chapters and supplementary fields')
const savedQuestions = [
{ id: 1, edit_input: 'multi_dimension', value: { n: 0, r: '旧标签' }, dimensions: [
{ field: 'n', edit_input: 'number' }, { field: 'r', selected_keys: ['no'] }] },
{ id: 2, edit_input: 'checkbox', selected_keys: ['other'], other_text: '补充内容', config: { other_key: 'other' } },
{ id: 3, edit_input: 'radio', selected_keys: ['0'] },
{ id: 4, edit_input: 'textarea', value: '原建议' }
]
let toast
const previousUni = globalThis.uni
globalThis.uni = { showToast: args => { toast = args.title } }
const completeProfile = { mobile: '13800000000', name: '测试学员', sex: '男', birthday: '1990-01-01', email: 'test@example.com', company_name: '测试公司', company_position: '经理' }
const returned = new Vue(page)
returned.$u = { api: { user: async () => ({ user: completeProfile }), courseEvaluationDetail: async () => ({ id: 8, title: '新标题', my_response: {
title: '提交时标题', desc: '<p>原说明</p>', questions: savedQuestions
} }) } }
await returned.load()
assert.equal(toast, '您已提交,可修改后重新提交')
assert.equal(returned.completed, true)
assert.equal(returned.survey.title, '提交时标题')
assert.equal(returned.questionList[0].value.n, 0)
assert.equal(returned.questionList[0].value.r, 'no')
assert.deepEqual([...returned.questionList[1].value], ['other'])
assert.equal(returned.questionList[1].show_other, true)
assert.equal(returned.questionList[1].other_text, '补充内容')
assert.equal(returned.questionList[2].value, '0')
assert.equal(returned.questionList[3].value, '原建议')
returned.setValue(returned.questionList[2], 'changed')
returned.setDimension(returned.questionList[0], { field: 'n' }, 5)
assert.equal(returned.questionList[2].value, 'changed')
assert.equal(returned.questionList[0].value.n, 5)
let confirmation
globalThis.uni.showModal = args => { confirmation = args.content; args.success({ confirm: false }) }
returned.submit()
assert.equal(confirmation, '确认重新提交?将更新之前的答案。')
globalThis.uni = previousUni
returned.$destroy()
console.log('Submitted survey reentry: snapshot restored, toast shown and editing and resubmission enabled')
const prefills = prepareQuestions([
{ id: 1, edit_input: 'text', config: { user_field: 'name' } },
{ id: 2, edit_input: 'text', config: { user_field: 'mobile' } },
{ id: 3, edit_input: 'text', config: { user_field: 'email' } },
{ id: 4, edit_input: 'text', config: { user_field: 'password' } }
])
fillUserFields(prefills, { name: '张三', mobile: '13800000000', password: 'secret' })
assert.equal(prefills[0].value, '张三')
assert.equal(prefills[1].value, '13800000000')
assert.equal(prefills[2].value, '')
assert.equal(prefills[3].value, '')
prefills[0].value = '自填姓名'
fillUserFields(prefills, { name: '张三' })
assert.equal(prefills[0].value, '自填姓名')
const gate = new Vue(page)
gate.id = '8'
gate.$u = { api: { user: async () => ({ user: {} }), courseEvaluationDetail: async () => ({ id: 8, course_content_evaluation_asks: [] }) } }
await gate.load()
assert.equal(gate.profileRequired, true)
assert.equal(gate.showRegister, true)
gate.submit() // no modal or API available; must be blocked
let destination
globalThis.uni = { redirectTo: args => { destination = args.url } }
gate.goIdentity('login')
assert.equal(destination, '/packages/register/login?survey_id=8')
gate.goIdentity('index')
assert.equal(destination, '/packages/register/index?survey_id=8')
globalThis.uni = previousUni
gate.$destroy()
console.log('Profile prefill, whitelist and identity gate passed')
const navigationSource = fs.readFileSync(new URL('../common/surveyNavigation.js', import.meta.url), 'utf8')
const { returnToSurvey } = await import('data:text/javascript;base64,' + Buffer.from(navigationSource).toString('base64'))
// Execute the actual binding/registration completion handlers with mocked APIs.
function identityPage(name) {
const text = fs.readFileSync(new URL('../packages/register/' + name + '.vue', import.meta.url), 'utf8')
const script = text.split('<script>')[1].split('</script>')[0]
.replace(/import[^\n]*from[^\n]*/g, '').replace('export default', 'return')
return new Function('CompanySelector', 'returnToSurvey', script)({}, returnToSurvey)
}
const bindingPage = identityPage('login')
const registrationPage = identityPage('index')
let identityRoute
const noop = () => {}
globalThis.uni = { redirectTo: args => { identityRoute = args.url; if (args.success) args.success() }, switchTab: args => { identityRoute = args.url }, removeStorageSync: noop, setStorageSync: noop }
const binding = {
survey_id: '', form: { mobile: '13800000000', code: '123456' }, sendTimer: null,
base: { isNull: value => !value, toast: noop },
$u: { vuex: noop, api: { checkMobile: async () => ({ token: 'test-token' }) } }
}
bindingPage.onLoad.call(binding, { survey_id: '8' })
bindingPage.methods.toRegister.call(binding)
assert.equal(identityRoute, '/packages/register/index?survey_id=8')
bindingPage.methods.handleMsgLogin.call(binding)
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(identityRoute, '/packages/surveyFill/index?id=8')
bindingPage.onLoad.call(binding, {})
bindingPage.methods.handleMsgLogin.call(binding)
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(identityRoute, '/pages/me/index')
const registration = {
form: { username: '张三' },
$refs: { uForm: { validate: callback => callback(true) } },
base: { toast: noop },
$u: { vuex: noop, api: { saveUser: async () => ({}), user: async () => ({ user: completeProfile }) } }
}
registrationPage.onLoad.call(registration, { survey_id: '8' })
registrationPage.methods.saveUser.call(registration)
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(identityRoute, '/packages/surveyFill/index?id=8')
registrationPage.onLoad.call(registration, { survey_id: 'https://invalid.example' })
assert.equal(registration.survey_id, '')
globalThis.uni = previousUni
console.log('Binding and registration callbacks return to the original survey; ordinary login retains its original destination')
const conditional = prepareQuestions([
{ id: 1, field: 'insurance', edit_input: 'radio' },
{ id: 2, field: 'identity', name: '身份证', edit_input: 'text', need_fill: true, config: { visible_when: { field: 'insurance', value: 'yes' } } },
{ id: 3, field: 'venues', name: '会场', edit_input: 'checkbox', config: { max_choices: 2, option_notes: { tax: { placeholder: '咨询问题' } } }, select_item: [{ key: 'tax', value: '财税' }, { key: 'legal', value: '法律' }, { key: 'ip', value: '知识产权' }] }
])
assert.equal(visibleQuestions(conditional).length, 2)
conditional[1].value = '预填证件'
assert.equal(submission(conditional).some(q => q.id === 2), false)
conditional[0].value = 'yes'
assert.equal(visibleQuestions(conditional).length, 3)
conditional[1].value = ''
assert.equal(firstError(conditional).id, 2)
conditional[1].value = '填写证件'
conditional[2].value = ['tax', 'legal', 'ip']
assert.match(firstError(conditional).message, /最多选择2项/)
conditional[2].value = ['tax']
conditional[2].option_texts = { tax: '财税问题', legal: '未选不提交' }
assert.equal(selectedNotes(conditional[2]).length, 1)
assert.deepEqual(submission(conditional)[2].option_texts, { tax: '财税问题' })
console.log('Conditional visibility, selection limits and per-option supplement submission passed')
// The first successful registration must navigate even when toast never calls back
// and profile refresh would fail. Concurrent clicks must not save twice.
let completeSave
let saves = 0
let profileReads = 0
const freshRegistration = {
survey_id: '9', saving: false, registrationComplete: false, form: { username: '新用户' },
$refs: { uForm: { validate: callback => callback(true) } },
base: { toast: noop },
$u: { vuex: noop, api: {
saveUser: () => { saves++; return new Promise(resolve => { completeSave = resolve }) },
user: () => { profileReads++; return Promise.reject(new Error('profile unavailable')) }
} }
}
globalThis.uni = { redirectTo: args => { identityRoute = args.url; args.success() } }
identityRoute = ''
const firstRegistration = registrationPage.methods.saveUser.call(freshRegistration)
registrationPage.methods.saveUser.call(freshRegistration)
await Promise.resolve()
assert.equal(saves, 1)
assert.equal(identityRoute, '')
completeSave('更新成功')
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
assert.equal(profileReads, 0)
assert.equal(freshRegistration.saving, false)
let fallbackRoute
uni.redirectTo = args => args.fail()
uni.reLaunch = args => { fallbackRoute = args.url; args.success() }
await returnToSurvey('9')
assert.equal(fallbackRoute, '/packages/surveyFill/index?id=9')
uni.reLaunch = args => args.fail(new Error('navigation failed'))
await assert.rejects(returnToSurvey('9'))
await registrationPage.methods.saveUser.call(freshRegistration)
assert.equal(saves, 1) // Retry navigation only, never save again.
assert.equal(freshRegistration.saving, false)
let bindingRequests = 0
const failedBinding = { ...binding, binding: false, bindingComplete: false, survey_id: '9', $u: { vuex: noop, api: { checkMobile: async () => { bindingRequests++; throw new Error('invalid code') } } } }
identityRoute = ''
await bindingPage.methods.handleMsgLogin.call(failedBinding)
assert.equal(bindingRequests, 1)
assert.equal(identityRoute, '')
assert.equal(failedBinding.binding, false)
assert.equal(failedBinding.bindingComplete, false)
globalThis.uni = previousUni
console.log('First registration navigates without toast/profile callbacks; duplicate clicks, navigation fallback/retry and binding failures verified')
const chained = [
{ id: 91, field: 'first', value: 'no' },
{ id: 92, field: 'second', value: 'yes', config: { visible_when: { field: 'first', value: 'yes' } } },
{ id: 93, field: 'third', value: 'old', config: { visible_when: { field: 'second', value: 'yes' } } }
]
assert.deepEqual(visibleQuestions(chained).map(q => q.id), [91])
assert.deepEqual(submission(chained).map(q => q.id), [91])
chained[0].value = 'yes'
assert.equal(visibleQuestions(chained).length, 3)
for (const [state, message] of [
[undefined, '您还未报名相关课程'],
['not_started', '该问卷尚未开始,请于2026年10月23日后扫码打开该问卷。'],
['ended', '该问卷已结束。'],
['draft', '该问卷还未开始。']
]) {
const denied = new Vue(page)
denied.id = 9
denied.questionList = prepareQuestions([{ id: 1, name: '旧问题', edit_input: 'text' }])
denied.survey = { title: '旧问卷' }
denied.showRegister = true
let calls = 0
denied.$u = { api: { courseEvaluationDetail: async () => { calls++; return { id: 9, access_state: state, access_error: message } },
user: () => { throw new Error('must not fetch profile') } } }
globalThis.uni = { showToast: () => { throw new Error('must show message on page') },
switchTab: () => { throw new Error('must stay on survey page') },
showModal: () => { throw new Error('must not submit unavailable survey') } }
await denied.load()
assert.equal(denied.unavailableMessage, message)
assert.equal(denied.error, '')
assert.equal(denied.loading, false)
assert.equal(denied.showRegister, false)
assert.deepEqual(denied.questionList, [])
assert.deepEqual(denied.survey, {})
denied.submit()
await denied.load()
assert.equal(calls, 2)
denied.$u.api.courseEvaluationDetail = async () => ({ id: 9, title: '已开始问卷', course_content_evaluation_asks: [{ id: 1, edit_input: 'text', name: '姓名' }] })
denied.$u.api.user = async () => ({ user: { mobile: '13800000000' } })
await denied.load()
assert.equal(denied.unavailableMessage, '')
assert.equal(denied.survey.title, '已开始问卷')
assert.equal(denied.questionList.length, 1)
denied.$destroy()
}
globalThis.uni = previousUni
console.log('Draft, future and ended surveys hide stale content, stay on page, block submit and can reload after opening')
// Access may be revoked after opening the form: submission must hide the old
// questions as well, without a toast, delayed navigation or registration popup.
const audienceRevoked = new Vue(page)
audienceRevoked.id = 9
audienceRevoked.survey = { title: '此前可填写', schema_hash: 'test' }
audienceRevoked.questionList = prepareQuestions([{ id: 1, name: '意见', edit_input: 'text' }])
audienceRevoked.questionList[0].value = '已填写内容'
audienceRevoked.$u = { api: { courseContentForm: async () => ({ access_error: '您还未报名相关课程' }) } }
let revokedSubmission
globalThis.uni = {
showModal: args => { revokedSubmission = args.success({ confirm: true }) },
showToast: () => { throw new Error('must display the audience message on page') },
switchTab: () => { throw new Error('must not return home') }
}
audienceRevoked.submit()
await revokedSubmission
assert.equal(audienceRevoked.unavailableMessage, '您还未报名相关课程')
assert.deepEqual(audienceRevoked.questionList, [])
assert.deepEqual(audienceRevoked.survey, {})
assert.equal(audienceRevoked.submitting, false)
assert.equal(audienceRevoked.showRegister, false)
audienceRevoked.$destroy()
globalThis.uni = previousUni
console.log('Audience denial at entry and submission hides questions, stays on page and supports reloading after approval')
const actualNow = Date.now
Date.now = () => Date.UTC(2026, 9, 22, 4, 0, 0) // 2026-10-22 12:00:00 Asia/Shanghai
try {
for (const [fields, message] of [
[{ status: '0', start_time: '2026-10-26 00:00:00' }, '该问卷尚未开始,请于2026年10月26日后扫码打开该问卷。'],
[{ status: 0 }, '该问卷还未开始。'],
[{ status: '0', start_time: '2026-10-26 09:30:15' }, '该问卷尚未开始,请于2026年10月26日 09:30:15后扫码打开该问卷。'],
[{ status: '0', start_time: '2026-10-21 00:00:00' }, '该问卷还未开始。'],
[{ status: '1', start_time: '2026-10-26 00:00:00' }, '该问卷尚未开始,请于2026年10月26日后扫码打开该问卷。'],
[{ status: 1, start_time: '2026-10-26 09:30:15' }, '该问卷尚未开始,请于2026年10月26日 09:30:15后扫码打开该问卷。'],
[{ status: 1, end_time: '2026-10-22 12:00:00' }, '该问卷已结束。'],
[{ access_error: '当前问卷已截止' }, '该问卷已结束。']
]) {
const denied = new Vue(page)
denied.id = 9
denied.$u = { api: { courseEvaluationDetail: async () => ({ id: 9, title: '不应显示', course_content_evaluation_asks: [{ id: 1, name: '不应显示的题目', edit_input: 'text' }], ...fields }),
user: () => { throw new Error('must not fetch profile before checking raw status and dates') } } }
globalThis.uni = { showToast: () => { throw new Error('must stay on message page') }, switchTab: () => { throw new Error('must not leave page') } }
await denied.load()
assert.equal(denied.unavailableMessage, message)
assert.equal(denied.error, '')
assert.deepEqual(denied.questionList, [])
assert.deepEqual(denied.survey, {})
denied.$destroy()
}
const available = new Vue(page)
assert.equal(available.availabilityError({ status: '1', start_time: '2026-10-22 12:00:00', end_time: '2026-10-23 00:00:00' }), null)
assert.equal(available.availabilityError({ status: '1', start_time: null, end_time: null }), null)
available.$destroy()
} finally {
Date.now = actualNow
globalThis.uni = previousUni
}
console.log('Legacy full responses: string draft status, future opening, deadline and Shanghai timezone boundaries are blocked before profile/questions load')
const fileQuestions = prepareQuestions([{ id: 301, edit_input: 'file', name: '文件', need_fill: true, config: { max_files: 1 } }])
assert.ok(firstError(fileQuestions))
fileQuestions[0].value = [{ id: 81, original_name: '材料.pdf' }]
assert.equal(firstError(fileQuestions), null)
assert.equal(submission(fileQuestions)[0].value[0].id, 81)
assert.equal(prepareQuestions(fileQuestions, true)[0].value[0].original_name, '材料.pdf')
fileQuestions[0].value.push({ id: 82 })
assert.ok(firstError(fileQuestions))
const uploadSource = fs.readFileSync(new URL('../packages/surveyFill/components/SurveyFile.vue', import.meta.url), 'utf8')
const uploadComponent = new Function('ROOTPATH', uploadSource.split('<script>')[1].split('</script>')[0].replace(/import[^\n]*/, '').replace('export default', 'return'))('https://example.invalid')
const uploader = new Vue({ ...uploadComponent, propsData: { value: [], limit: 2 } })
const events = []
uploader.$on('input', value => events.push(value))
const states = []
uploader.$on('uploading', value => states.push(value))
globalThis.uni = { getStorageSync: () => ({ vuex_token: 'test' }), uploadFile: options => options.success({ statusCode: 200, data: JSON.stringify({ id: 81, url: '/file.pdf', original_name: options.formData.original_name }) }), showToast: () => {} }
await uploader.upload([{ path: '/tmp/a.pdf', name: '材料.pdf', size: 100 }])
assert.equal(events[0][0].original_name, '材料.pdf')
assert.deepEqual(states, [true, false])
uni.uploadFile = options => options.fail()
await uploader.upload([{ path: '/tmp/b.pdf', name: 'b.pdf', size: 100 }])
assert.equal(events.length, 1)
assert.equal(uploader.uploading, false)
uploader.$destroy()
console.log('File answers: required, limits, restore, upload success and failure verified')
const restricted = new Vue({ ...uploadComponent, propsData: { value: [], limit: 2, extensions: ['pdf'], questionId: 301 } })
let uploads = 0, lastNotice = ''
globalThis.uni = { getStorageSync: () => ({ vuex_token: 'test' }), showToast: o => { lastNotice = o.title }, uploadFile: o => {
uploads++
assert.equal(o.formData.survey_question_id, 301)
o.success({ statusCode: 200, data: JSON.stringify({ id: 91, url: '/a.pdf' }) })
} }
await restricted.upload([{ path: '/tmp/no.docx', name: 'no.docx', size: 10 }])
assert.equal(uploads, 0)
assert.ok(lastNotice.includes('pdf'))
await restricted.upload([{ path: '/tmp/yes.PDF', name: 'yes.PDF', size: 10 }])
assert.equal(uploads, 1)
restricted.$destroy()
console.log('Configured upload formats reject unsupported files and allow uppercase suffixes')
assert.ok(!fieldSource.split('<script>')[0].includes('|| {}'))
assert.equal(field.computed.fileLimit.call({ field: {} }), 1)
assert.equal(field.computed.fileExtensions.call({ field: { config: { allowed_extensions: ['pdf'] } } })[0], 'pdf')
console.log('Upload template uses computed bindings without WXML object literals')
assert.ok(pageSource.includes('q.help && q.edit_input !=='))
assert.ok(pageSource.includes('q.config.max_choices > 0'))
assert.ok(!uploadSource.split('<script>')[0].includes('点击查看'))
assert.ok(!uploadSource.split('<script>')[0].includes('单文件小于'))
assert.ok(!uploadSource.split('<script>')[0].includes('@click="open(file)"'))
for (const help of ['请简要说明', '', ' ']) {
assert.equal(field.computed.textPlaceholder.call({ field: { help } }), help.trim() || '请输入')
}
assert.ok(pageSource.includes("q.edit_input !== 'text' && q.edit_input !== 'textarea'"))
const financing = prepareQuestions([
{ id: 601, name: '融资', field: 'finance', edit_input: 'radio' },
{ id: 602, name: '材料', field: 'material', edit_input: 'file', need_fill: true, rule: 'required', config: { visible_when: { field: 'finance', values: ['later', 'road'], required_values: ['road'] } } }
])
financing[0].value = 'later'
assert.equal(visibleQuestions(financing).length, 2)
assert.equal(required(financing[1], financing), false)
assert.equal(firstError(financing), null)
financing[0].value = 'road'
assert.equal(required(financing[1], financing), true)
assert.equal(firstError(financing).id, 602)
financing[0].value = 'no'
assert.equal(visibleQuestions(financing).length, 1)
assert.equal(firstError(financing), null)
assert.equal(submission(financing).length, 1)
financing[0].edit_input = 'checkbox'
financing[0].value = ['later', 'road']
assert.equal(required(financing[1], financing), true)
financing[0].value = ['later']
assert.equal(required(financing[1], financing), false)
console.log('Multiple visibility triggers and required overrides verified')
const incomplete = new Vue(page)
incomplete.$u = { api: { user: async () => ({ user: { mobile: '13800000000' } }), courseEvaluationDetail: async () => ({ id: 9, requires_profile: true }) } }
await incomplete.load()
assert.equal(incomplete.profileRequired, false)
assert.equal(incomplete.showRegister, false)
incomplete.$destroy()
identityRoute = ''
globalThis.uni = { redirectTo: args => { identityRoute = args.url; args.success() }, removeStorageSync: noop, setStorageSync: noop }
const needsProfile = { ...binding, binding: false, bindingComplete: false, survey_id: '9', $u: { vuex: noop, api: { checkMobile: async () => ({ token: 'test', requires_profile: true }) } } }
await bindingPage.methods.handleMsgLogin.call(needsProfile)
assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
const registrationPrefill = { form: {}, survey_id: '', base: { toast: noop }, $u: { api: { user: async () => ({ user: completeProfile }) } } }
registrationPage.onLoad.call(registrationPrefill, { survey_id: '9' })
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(registrationPrefill.form.username, completeProfile.name)
assert.equal(registrationPrefill.form.mobile, completeProfile.mobile)
assert.equal(registrationPrefill.form.company_name, completeProfile.company_name)
globalThis.uni = previousUni
console.log('Mobile-only survey gate, binding return and verified profile prefill passed')
let automaticSaves = 0
identityRoute = ''
globalThis.uni = { redirectTo: args => { identityRoute = args.url; args.success() } }
const automaticRegistration = {
survey_id: '9', form: { ...completeProfile, username: completeProfile.name }, myMobile: completeProfile.mobile, myCode: '123456',
saving: false, bindingMobile: false, registrationComplete: false, isBasicInfoIncomplete: () => false,
$refs: { uForm: { validate: callback => callback(true) } },
base: { toast: noop, isNull: value => !value },
$u: { api: { bindMobile: async () => ({ id: 200, mobile: completeProfile.mobile }), saveUser: async payload => { automaticSaves++; assert.equal(payload.survey_id, '9'); return '更新成功' } } },
$set: (target, key, value) => { target[key] = value }, $nextTick: () => Promise.resolve(),
saveUser(options) { return registrationPage.methods.saveUser.call(this, options) }
}
await registrationPage.methods.changeMobile.call(automaticRegistration)
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(automaticSaves, 1)
assert.equal(automaticRegistration.registrationComplete, true)
assert.equal(identityRoute, '')
automaticRegistration.form.company_position = '修改后职务'
automaticRegistration.$u.api.saveUser = async payload => {
automaticSaves++
assert.equal(payload.company_position, '修改后职务')
return '更新成功'
}
assert.equal(await automaticRegistration.saveUser(), true)
assert.equal(automaticSaves, 2)
assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
globalThis.uni = previousUni
console.log('Auto-registration stays on the page; explicit registration saves edits before returning')
// Simulate the u-form child receiving its phone prop only on Vue's next update.
let boundRequests = 0, registeredRequests = 0, finishRegistration
const reactiveRegistration = new Vue(registrationPage)
reactiveRegistration.form = { ...reactiveRegistration.form, ...completeProfile, username: completeProfile.name, mobile: '' }
reactiveRegistration.survey_id = '9'
reactiveRegistration.myMobile = completeProfile.mobile
reactiveRegistration.myCode = '123456'
reactiveRegistration.base = { isNull: value => value == null || !String(value).trim(), toast: noop }
let renderedMobile = ''
reactiveRegistration.$watch(() => reactiveRegistration.form.mobile, value => { renderedMobile = value })
reactiveRegistration.$refs.uForm = { validate: callback => {
assert.equal(renderedMobile, completeProfile.mobile, 'the bound phone must reach the form before validation')
callback(true)
} }
reactiveRegistration.$u = { api: {
bindMobile: async () => { boundRequests++; return { id: 200, mobile: completeProfile.mobile } },
saveUser: payload => {
registeredRequests++
assert.equal(payload.mobile, completeProfile.mobile)
assert.equal(payload.survey_id, '9')
return new Promise(resolve => { finishRegistration = resolve })
}
} }
identityRoute = ''
globalThis.uni = { redirectTo: options => { identityRoute = options.url; options.success() } }
const registrationChain = reactiveRegistration.changeMobile()
reactiveRegistration.changeMobile()
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(boundRequests, 1)
assert.equal(registeredRequests, 1)
assert.equal(identityRoute, '', 'must wait for the registration API to succeed')
assert.equal(reactiveRegistration.saving, true)
reactiveRegistration.changeMobile()
assert.equal(boundRequests, 1)
finishRegistration('更新成功')
assert.equal(await registrationChain, true)
assert.equal(identityRoute, '')
reactiveRegistration.form.username = '修改后的姓名'
const explicitRegistration = reactiveRegistration.saveUser()
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(registeredRequests, 2)
assert.equal(identityRoute, '')
finishRegistration('更新成功')
assert.equal(await explicitRegistration, true)
assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
assert.equal(reactiveRegistration.saving, false)
assert.equal(reactiveRegistration.bindingMobile, false)
reactiveRegistration.$destroy()
globalThis.uni = previousUni
console.log('Reactive phone sync and the full bind/register/return promise chain verified')
// Saving or validation failures must not navigate; retry saving uses the verified phone.
const failedRegistration = {
...automaticRegistration, registrationComplete: false, saving: false, bindingMobile: false, myCode: '123456',
form: { ...automaticRegistration.form },
$u: { api: { bindMobile: async () => ({ id: 200 }), saveUser: async () => false } }
}
identityRoute = ''
globalThis.uni = { redirectTo: options => { identityRoute = options.url; options.success() } }
assert.equal(await registrationPage.methods.changeMobile.call(failedRegistration), false)
assert.equal(failedRegistration.registrationComplete, false)
assert.equal(failedRegistration.saving, false)
assert.equal(identityRoute, '')
failedRegistration.$u.api.saveUser = async () => '更新成功'
assert.equal(await registrationPage.methods.saveUser.call(failedRegistration), true)
assert.equal(identityRoute, '/packages/surveyFill/index?id=9')
let invalidSaves = 0
const invalidRegistration = {
...failedRegistration, registrationComplete: false, saving: false, myCode: '123456',
$refs: { uForm: { validate: callback => callback(false) } },
$u: { api: { bindMobile: async () => ({ id: 200 }), saveUser: async () => { invalidSaves++; return '更新成功' } } }
}
identityRoute = ''
assert.equal(await registrationPage.methods.changeMobile.call(invalidRegistration), false)
assert.equal(invalidSaves, 0)
assert.equal(identityRoute, '')
globalThis.uni = previousUni
console.log('Registration failure/validation failure never navigate; registration can retry without rebinding')