import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import * as vue from 'vue' import { renderToString } from 'vue/server-renderer' import { compileScript, parse } from '@vue/compiler-sfc' import ts from 'typescript' const { descriptor } = parse(readFileSync('src/views/settings/wecom/scenes/index.vue', 'utf8')) const compiled = ts.transpileModule( compileScript(descriptor, { id: 'wecom-mappings-test' }).content, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } } ).outputText const field = (code, name = code, required_mapping = false) => ({ code, name, required_mapping, value_type: 'string', description: `${name}说明` }) const refundFields = [ ['refund_no', '退款单号'], ['order_id', '订单 ID'], ['order_no', '订单号'], ['asset_identifier', '资产标识'], ['actual_received_amount', '订单实收金额'], ['requested_refund_amount', '申请退款金额'], ['refund_voucher_key', '退款凭证'], ['refund_reason', '退款原因'], ['package_usage_id', '套餐使用记录 ID'], ['refund_asset_type', '资产类型', true], ['refund_device_type', '设备类型', true], ['refund_device_model', '设备型号', true], ['refund_package_used_mb', '当前退款套餐已用量(MB)', true], ['refund_package_total_mb', '当前退款套餐总量(MB)', true], ['refund_channel_trade_no', '原支付渠道交易流水号', true], ['refund_applicant_remark', '申请人备注', true], ['refund_method_code', '退款方式编码'], ['refund_method', '退款方式', true], ['customer_account_info', '客户收款信息'], ['customer_voucher_key', '客户收款凭证'], ['submitter_id', '提交人账号 ID'], ['submitter_name', '提交人名称'] ].map((args) => field(...args)) const control = (id, required = false) => ({ id, title: `控件 ${id}`, type: 'Text', required }) const response = (data) => ({ code: 0, data }) const scene = (mapping = []) => ({ business_type: 'refund_approval', application_id: 1, template_id: 'template-1', status: 1, control_mapping: mapping }) const settle = () => new Promise(setImmediate) function deferred() { let resolve, reject const promise = new Promise((yes, no) => { resolve = yes reject = no }) return { promise, resolve, reject } } function harness(overrides = {}) { const calls = { fields: [], templates: [], saves: [], warnings: [], errors: [] } const service = { getBusinessFields: async (type) => { calls.fields.push(type) return response({ items: refundFields }) }, inspectTemplate: async (...args) => { calls.templates.push(args) return response({ name: '模板', controls: [control('text-1')] }) }, saveScene: async (...args) => { calls.saves.push(args) return response({}) }, getApplications: async () => response({ items: [] }), getScenes: async () => response({ items: [] }), ...overrides } const imports = { vue: { ...vue, onMounted() {} }, 'element-plus': { ElMessage: { warning: (message) => calls.warnings.push(message), error: (message) => calls.errors.push(message), success() {} }, ElTag: {} }, '@/composables/useCheckedColumns': { useCheckedColumns: () => ({ columns: vue.ref([]), columnChecks: vue.ref([]) }) }, '@/composables/useAuth': { useAuth: () => ({ hasAuth: () => true }) }, '@/api/modules': { WecomService: service }, '@/config/constants': { JULY_PERMISSIONS: { wecom: {} } }, '@/utils/business/format': { formatDateTime: (value) => value } } const module = { exports: {} } new Function('require', 'module', 'exports', compiled)( (name) => { assert.ok(imports[name], `Unexpected dependency: ${name}`) return imports[name] }, module, module.exports ) const state = module.exports.default.setup({}, { expose() {} }) return { state, service, calls } } test('opening a new scene displays all 22 API fields before any template is synchronized', async () => { const { state, calls } = harness() state.createScene() await settle() assert.deepEqual(calls.fields, ['refund_approval']) assert.equal(calls.templates.length, 0) assert.equal(state.mappingRows.value.length, 22) assert.deepEqual( state.mappingRows.value.map((row) => row.business_field), refundFields.map((f) => f.code) ) for (const [index, row] of state.mappingRows.value.entries()) { assert.equal(row.field_name, refundFields[index].name) assert.equal(row.required_mapping, refundFields[index].required_mapping) assert.equal(row.control_id, '') } }) test('selecting a business type immediately replaces old rows with all fields for that type', async () => { const { state, service } = harness() state.createScene() await settle() service.getBusinessFields = async (type) => { assert.equal(type, 'offline_recharge_approval') return response({ items: [field('recharge_no', '充值单号', true), field('remark', '备注')] }) } state.sceneForm.business_type = 'offline_recharge_approval' state.handleBusinessTypeChange() assert.equal(state.mappingRows.value.length, 0) await settle() assert.deepEqual( state.mappingRows.value.map((row) => row.business_field), ['recharge_no', 'remark'] ) }) for (const templateFirst of [false, true]) { test(`editing retains saved bindings and option mappings when ${templateFirst ? 'template' : 'fields'} responds first`, async () => { const fields = deferred() const template = deferred() const { state } = harness({ getBusinessFields: () => fields.promise, inspectTemplate: () => template.promise }) state.editScene( scene([ { business_field: 'refund_method', control_id: 'select-1', control_type: 'Selector', option_mapping: { original: '原路退回' } } ]) ) const finishFields = () => fields.resolve(response({ items: refundFields })) const finishTemplate = () => template.resolve( response({ name: '退款模板', controls: [{ ...control('select-1'), type: 'Selector' }] }) ) ;(templateFirst ? finishTemplate : finishFields)() await settle() ;(templateFirst ? finishFields : finishTemplate)() await settle() assert.equal(state.mappingRows.value.length, 22) const mapping = state.mappingRows.value.find((row) => row.business_field === 'refund_method') assert.equal(mapping.control_id, 'select-1') assert.equal(mapping.control_title, '控件 select-1') assert.equal(mapping.control_type, 'Selector') assert.deepEqual(JSON.parse(mapping.option_mapping), { original: '原路退回' }) }) } test('resyncing templates keeps all business rows but clears controls removed from the template', async () => { const { state, service } = harness() state.editScene( scene([{ business_field: 'refund_no', control_id: 'text-1', control_type: 'Text' }]) ) await settle() service.inspectTemplate = async () => response({ name: '新模板', controls: [control('text-2')] }) await state.syncTemplate() assert.equal(state.mappingRows.value.length, 22) assert.equal(state.mappingRows.value[0].control_id, '') assert.equal(state.mappingRows.value[0].control_type, '') }) test('required_mapping blocks saving missing business bindings; optional blank fields are not submitted', async () => { const { state, calls } = harness({ getBusinessFields: async () => response({ items: [field('required', '必填字段', true), field('optional')] }) }) state.editScene(scene()) await settle() await state.saveScene() assert.equal(calls.saves.length, 0) assert.match(calls.warnings.at(-1), /必填业务字段“必填字段”/) const row = state.mappingRows.value[0] row.control_id = 'text-1' state.handleControlChange(row) await state.saveScene() assert.deepEqual(calls.saves[0], [ 'refund_approval', { application_id: 1, template_id: 'template-1', status: 1, control_mapping: [ { business_field: 'required', control_id: 'text-1', control_type: 'Text', option_mapping: {} } ] } ]) }) test('required template controls still require a business field independently of required_mapping', async () => { const { state, calls } = harness({ getBusinessFields: async () => response({ items: [field('optional')] }), inspectTemplate: async () => response({ controls: [control('required-control', true)] }) }) state.editScene(scene()) await settle() assert.equal(state.parseMapping(), undefined) assert.match(calls.warnings.at(-1), /必填控件/) }) test('selecting and clearing controls updates control IDs and types without changing the business field', async () => { const { state } = harness() state.editScene(scene()) await settle() const row = state.mappingRows.value[0] row.control_id = 'text-1' state.handleControlChange(row) assert.equal(row.control_type, 'Text') assert.equal(row.control_title, '控件 text-1') row.control_id = '' state.handleControlChange(row) assert.equal(row.control_type, '') assert.equal(row.control_title, '') assert.equal(row.business_field, 'refund_no') }) test('out-of-order business field responses cannot overwrite a newer business type', async () => { const older = deferred() const newer = deferred() const { state } = harness({ getBusinessFields: (type) => (type === 'refund_approval' ? older.promise : newer.promise) }) state.createScene() state.sceneForm.business_type = 'offline_recharge_approval' state.handleBusinessTypeChange() newer.resolve(response({ items: [field('new-type')] })) await settle() older.resolve(response({ items: refundFields })) await settle() assert.equal(state.mappingRows.value[0].business_field, 'new-type') assert.equal(state.fieldsLoading.value, false) }) test('resetting the editor rejects pending responses even when the business type is unchanged', async () => { const pending = deferred() const { state, service } = harness({ getBusinessFields: () => pending.promise }) state.createScene() service.getBusinessFields = async () => response({ items: [field('new-session')] }) state.createScene() await settle() pending.resolve(response({ items: refundFields })) await settle() assert.equal(state.mappingRows.value[0].business_field, 'new-session') }) test('changing template settings rejects pending template responses and clears old control bindings', async () => { const pending = deferred() const { state } = harness({ inspectTemplate: () => pending.promise }) state.editScene(scene()) await settle() state.sceneForm.template_id = 'new-template' state.handleTemplateChange() pending.resolve(response({ name: '过期模板', controls: [control('old-control')] })) await settle() assert.equal(state.templateControls.value.length, 0) assert.equal(state.templateLoaded.value, false) assert.equal(state.mappingRows.value.length, 22) }) test('loading or failed field discovery cannot save an empty incomplete mapping', async (t) => { const pending = deferred() const { state, calls } = harness({ getBusinessFields: () => pending.promise }) t.mock.method(console, 'error', () => {}) state.sceneForm.template_id = 'template' const loading = state.loadBusinessFields('refund_approval') await state.saveScene() assert.equal(calls.saves.length, 0) pending.reject(new Error('network unavailable')) await loading await state.saveScene() assert.equal(calls.saves.length, 0) assert.equal(state.businessFieldsLoaded.value, false) assert.equal(calls.errors.length, 1) }) function findMappingSection(node) { if ( node.type === 1 && node.props.some( (prop) => prop.name === 'class' && prop.value?.content.includes('mapping-section') ) ) return node return node.children?.map(findMappingSection).find(Boolean) } test('rendered mapping section shows all 22 field names and exactly eight required stars before template sync', async () => { const { state } = harness() state.createScene() await settle() const section = findMappingSection(descriptor.template.ast) const app = vue.createSSRApp({ setup: () => ({ ...state }), render: vue.compile(section.loc.source) }) for (const name of ['ElTag', 'ElSelect', 'ElOption', 'ElInput', 'ElEmpty']) { app.component( name, vue.defineComponent({ setup: (_, { attrs, slots }) => () => vue.h('div', attrs, slots.default?.()) }) ) } app.directive('loading', { getSSRProps: () => ({}) }) const html = await renderToString(app) assert.equal((html.match(/class="mapping-row"/g) || []).length, 22) assert.equal((html.match(/aria-label="必填"/g) || []).length, 8) for (const field of refundFields) assert.ok(html.includes(field.name), field.name) assert.match(html, /请先同步模板/) assert.doesNotMatch(html, /暂无控件映射,请新增一条/) assert.doesNotMatch(html, /控件 ID|选择模板控件后自动填充/) assert.doesNotMatch(html, /mapping-field__description/) for (const field of refundFields) assert.ok(!html.includes(field.description), field.description) })