Files
zyt/admin/tests/appointment-call-mode.test.cjs
T
2026-09-09 15:47:48 +08:00

147 lines
8.5 KiB
JavaScript

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, /<VideoCallPicker v-if="isCallReady && canVideoCall"/)
assert.match(descriptor.template.content, /<AudioCallPicker v-if="isCallReady && canAudioCall"/)
})
test('all SDK outgoing paths stop before touching SDK when current appointment is text', async () => {
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 }), /先打开/)
})