const assert = require('node:assert/strict') const fs = require('node:fs') const path = require('node:path') const test = require('node:test') const ts = require('typescript') const vue = require('vue') const { renderToString } = require('@vue/server-renderer') const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc') const src = path.join(__dirname, '../src') function moduleFrom(source, mockRequire = require, browserWindow = {}) { const code = ts.transpileModule(source.replaceAll('import.meta.env.DEV', 'false'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', 'window', code)(mockRequire, module, module.exports, browserWindow) return module.exports } const read = (file) => fs.readFileSync(path.join(src, file), 'utf8') const types = moduleFrom(read('utils/appointment-type.ts')) const { descriptor } = parse(read('components/chat-dialog/index.vue')) const script = compileScript(descriptor, { id: 'appointment-call-mode' }) const template = compileTemplate({ source: descriptor.template.content, filename: 'chat-dialog.vue', id: 'appointment-call-mode', compilerOptions: { bindingMetadata: script.bindings } }) assert.deepEqual(template.errors, []) const render = moduleFrom(template.code).render const passthrough = { setup: (_, { slots }) => () => vue.h('div', [slots.default?.(), slots.headerToolbar?.()]) } const picker = text => ({ setup: () => () => vue.h('button', text) }) function chat(policy = {}) { const requests = [], warnings = [], sdkCalls = [] const guard = moduleFrom(read('utils/appointment-call-guard.ts')) const server = Object.fromEntries(['calls', 'call', 'groupCall'].map(name => [name, async params => { sdkCalls.push({ name, params }) }])) const api = { getCallSignature: async params => { requests.push(params); return policy } } const result = moduleFrom(script.content, name => { if (name === 'vue') return { ...vue, onMounted() {}, onUnmounted() {} } if (name === '@/utils/appointment-type') return types if (name === '@/utils/appointment-call-guard') return guard if (name === '@/utils/im-chat-archive-trigger') return moduleFrom(read('utils/im-chat-archive-trigger.ts')) if (name === '@/api/tcm') return api if (name === '@/utils/feedback') return { default: { msgWarning: text => warnings.push(text) } } if (name === '@/stores/modules/user') return { default: () => ({ userInfo: {} }) } if (name === '@/utils/call-local-recorder') return { CallLocalRecorder: class {} } if (name === '@tencentcloud/chat-uikit-vue3') return { useLoginState: () => ({}), useConversationListState: () => ({ activeConversation: vue.ref(null) }), Chat: passthrough, UIKitProvider: passthrough, MessageInput: passthrough, MessageList: passthrough, EmojiPicker: picker('表情'), ImagePicker: picker('图片'), FilePicker: picker('文件'), AudioCallPicker: picker('语音拨打'), VideoCallPicker: picker('视频拨打') } if (name === '@tencentcloud/call-uikit-vue') return { TUICallKitServer: server, TUICallType: { VIDEO_CALL: 2 }, TUIStore: {}, StoreName: {}, NAME: {} } return {} }).default.setup({}, { expose() {} }) result.visible.value = true result.isReady.value = true result.isCallReady.value = true result.patientId.value = 50 result.diagnosisId.value = 70 result.appointmentId.value = 90 result.syncAppointmentCallPolicy(policy) guard.registerAppointmentCallGuard(result.prepareAppointmentCall) return { state: result, guard, server, requests, warnings, sdkCalls, api } } const policy = type => ({ appointment_id: 90, appointment_type: type, appointment_type_desc: types.appointmentTypeDescription(type), can_video_call: type === 'video', can_audio_call: type === 'video' || type === 'phone', call_disabled_reason: type === 'text' ? '图文问诊不支持音视频通话' : '当前挂号不支持该通话方式' }) test('type labels and video capability preserve legacy video while rejecting text and unknown', () => { for (const value of ['text', 'phone', 'unknown']) assert.equal(types.canAppointmentVideoCall(value), false) for (const value of ['video', '', ' ', null, undefined]) assert.equal(types.canAppointmentVideoCall(value), true) assert.equal(types.appointmentTypeDescription('text'), '图文问诊') }) test('rendered real chat template keeps text tools while excluding call controls in text mode', async () => { async function markup(type) { const { state } = chat(policy(type)) const app = vue.createSSRApp({ render: () => render({}, [], {}, vue.proxyRefs(state)) }) app.config.warnHandler = () => {} for (const name of ['el-tag', 'el-button', 'el-tooltip', 'el-icon', 'el-alert']) app.component(name, passthrough) const context = {} await renderToString(app, context) return context.teleports.body } const text = await markup('text') assert.match(text, /图文问诊/) for (const tool of ['表情', '图片', '文件']) assert.match(text, new RegExp(tool)) assert.doesNotMatch(text, /视频拨打|语音拨打|群视频/) const video = await markup('video') assert.match(video, /视频问诊/) assert.match(video, /视频拨打/) assert.match(video, /群视频/) }) test('text mode hides all live call entries and refuses direct group invocation', async () => { const { state, requests, sdkCalls } = chat(policy('text')) assert.equal(state.appointmentTypeLabel.value, '图文问诊') assert.equal(state.canVideoCall.value, false) assert.equal(state.canAudioCall.value, false) await state.startGroupVideoCall() await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/) assert.deepEqual(requests, []) assert.deepEqual(sdkCalls, []) assert.match(descriptor.template.content, / { const { state, server, sdkCalls } = chat(policy('text')) state.installTUICallKitRoomHooks() for (const method of ['call', 'calls', 'groupCall']) await assert.rejects(server[method]({ type: 2 }), /图文问诊/) assert.deepEqual(sdkCalls, []) }) test('video preflight uses actual appointment and prevents a server-side change to text', async () => { const { state, requests, api } = chat(policy('video')) assert.equal(state.canVideoCall.value, true) await state.prepareAppointmentCall(2) assert.deepEqual(requests, [{ patient_id: 50, diagnosis_id: 70, appointment_id: 90 }]) api.getCallSignature = async () => policy('text') await assert.rejects(state.prepareAppointmentCall(2), /图文问诊/) assert.equal(state.canVideoCall.value, false) assert.equal(state.appointmentTypeLabel.value, '图文问诊') }) test('historical phone is audio-only; missing server confirmation never enables video', async () => { const { state } = chat(policy('phone')) assert.equal(state.canAudioCall.value, true) assert.equal(state.canVideoCall.value, false) await state.prepareAppointmentCall(1) await assert.rejects(state.prepareAppointmentCall(2)) state.syncAppointmentCallPolicy({ appointment_type: 'video' }) assert.equal(state.canVideoCall.value, false) state.syncAppointmentCallPolicy({ appointment_type: null, appointment_type_desc: '未挂号', can_video_call: false, can_audio_call: false }) assert.equal(state.appointmentTypeLabel.value, '未挂号') assert.equal(state.canVideoCall.value, false) }) test('singleton call guard switches to current chat and cancels a pending old context', async () => { const guard = moduleFrom(read('utils/appointment-call-guard.ts')) let resolveOld const releaseOld = guard.registerAppointmentCallGuard(() => new Promise(resolve => { resolveOld = resolve })) const pending = guard.checkAppointmentOutgoingCall({ type: 2 }) const releaseNew = guard.registerAppointmentCallGuard(async () => { throw new Error('图文问诊') }) releaseOld() await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /图文问诊/) resolveOld() await assert.rejects(pending, /问诊已切换/) releaseNew() await assert.rejects(guard.checkAppointmentOutgoingCall({ type: 2 }), /先打开/) })