Files
zyt/admin/tests/consultation-transcripts.test.cjs
2026-10-22 11:02:57 +08:00

215 lines
12 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 { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const { renderToString } = require('@vue/server-renderer')
const read = name => fs.readFileSync(path.join(__dirname, '../src', name), 'utf8')
function moduleFrom(source, dependencies = require, globals = {}) {
const code = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText
const module = { exports: {} }
new Function('require', 'module', 'exports', ...Object.keys(globals), code)(dependencies, module, module.exports, ...Object.values(globals))
return module.exports
}
const util = moduleFrom(read('utils/consultation-transcripts.ts'))
const row = (id, diagnosis = 101, text = `医生:记录${id}的问题\n患者:记录${id}的回答`) => ({
id, diagnosis_id: diagnosis, call_type: 2, status: 2, start_time: 1792444800,
start_time_text: '2026-10-20 09:20:00', end_time_text: '2026-10-20 09:30:00',
duration_text: '10分钟', transcript_text: text, transcription_status: 'completed', transcription_segment_count: 2
})
const drain = () => new Promise(resolve => setImmediate(resolve))
test('each call keeps its own transcript even when multiple visits have the same date', () => {
const rows = util.normalizeConsultationRecords([row(10), row(11), row(12, 202, '串诊单文字'), row(10), null, { id: 0 }], 101)
assert.deepEqual(rows.map(item => item.id), [11, 10])
assert.equal(rows[0].transcript_text, row(11).transcript_text)
assert.equal(rows[1].transcript_text, row(10).transcript_text)
assert.throws(() => util.normalizeConsultationRecords({ lists: [] }, 101), /格式异常/)
const legacy = util.normalizeConsultationRecords([{ id: '13', transcript_text: '医生:你好\r\n患者:你好' }], 101)[0]
assert.equal(legacy.diagnosis_id, 101)
assert.equal(legacy.transcript_text, '医生:你好\n患者:你好')
})
test('running, partial and failed calls retain their saved words without pretending they are complete', () => {
for (const [status, label] of [['running', '录音转写中'], ['partial', '文字部分保存'], ['failed', '文字生成失败']]) {
const record = { ...row(1), transcription_status: status }
assert.equal(util.consultationTranscriptStatus(record).label, label)
assert.equal(util.consultationTranscriptText(record), row(1).transcript_text)
}
assert.match(util.consultationTranscriptEmptyText({ ...row(1), transcription_status: 'running', transcript_text: '' }), /正在转写/)
assert.match(util.consultationTranscriptEmptyText({ status: 3 }), /未接听/)
assert.match(util.consultationTranscriptEmptyText({ status: 4 }), /已取消/)
assert.equal(util.consultationTranscriptStatus({ transcript_text: '历史已保存文字' }).label, '文字已保存')
})
function hookFixture(initial = 101) {
const id = vue.ref(initial), requests = [], cleanup = []
const hook = moduleFrom(read('hooks/useConsultationTranscripts.ts'), name => {
if (name === 'vue') return { ...vue, onBeforeUnmount: fn => cleanup.push(fn) }
if (name === '@/api/tcm') return { getConsultationTranscripts: (params, signal) => new Promise((resolve, reject) => requests.push({ params, signal, resolve, reject })) }
if (name === '@/utils/consultation-transcripts') return util
throw new Error(name)
})
const scope = vue.effectScope()
const state = scope.run(() => hook.useConsultationTranscripts(() => id.value))
return { id, requests, state, dispose() { cleanup.forEach(fn => fn()); scope.stop() } }
}
test('changing diagnosis clears the old words immediately and ignores late responses and failures', async () => {
const f = hookFixture()
assert.deepEqual(f.requests[0].params, { diagnosis_id: 101 })
f.id.value = 202
assert.equal(f.requests[0].signal.aborted, true)
assert.deepEqual(f.state.records.value, [])
f.requests[1].resolve([row(20, 202), row(99, 101, '不属于该诊单')])
await drain()
assert.deepEqual(f.state.records.value.map(item => item.id), [20])
f.requests[0].reject(new Error('旧诊单请求失败'))
await drain()
assert.deepEqual(f.state.records.value.map(item => item.id), [20])
assert.equal(f.state.error.value, '')
f.id.value = 303
assert.deepEqual(f.state.records.value, [])
f.dispose()
assert.equal(f.requests[2].signal.aborted, true)
f.requests[2].resolve([row(30, 303)])
await drain()
assert.deepEqual(f.state.records.value, [])
})
test('manual refresh preserves current records on failure and recovers on retry without writes', async () => {
const f = hookFixture()
f.requests[0].resolve([row(10)])
await drain()
const failed = f.state.refresh()
f.requests[1].reject(new Error('网络连接失败'))
await failed
assert.equal(f.state.records.value[0].id, 10)
assert.equal(f.state.error.value, '网络连接失败')
assert.equal(f.state.loading.value, false)
const retried = f.state.refresh()
f.requests[2].resolve([row(11)])
await retried
assert.equal(f.state.error.value, '')
assert.equal(f.state.records.value[0].id, 11)
f.id.value = 0
assert.equal(f.requests.length, 3)
assert.deepEqual(f.state.records.value, [])
f.dispose()
})
const filename = 'views/tcm/diagnosis/components/ConsultationTranscriptPanel.vue'
const { descriptor, errors } = parse(read(filename), { filename })
assert.deepEqual(errors, [])
const script = compileScript(descriptor, { id: 'consultation-transcript-panel' })
const template = compileTemplate({ source: descriptor.template.content, filename, id: 'consultation-transcript-panel', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const render = moduleFrom(template.code).render
function panelFixture(records, extra = {}) {
const props = vue.reactive({ diagnosisId: 101 })
const data = { records: vue.ref(records), loading: vue.ref(false), error: vue.ref(''), refresh() {}, ...extra }
const copied = [], notices = []
const scope = vue.effectScope()
const component = moduleFrom(script.content, name => {
if (name === 'vue') return vue
if (name === '@/hooks/useConsultationTranscripts') return { useConsultationTranscripts: () => data }
if (name === '@/utils/consultation-transcripts') return util
if (name === '@/utils/feedback') return { default: { msgWarning: text => notices.push(text), msgSuccess: text => notices.push(text) } }
if (name === '@element-plus/icons-vue') return { Refresh: { render: () => vue.h('svg') }, Search: {} }
throw new Error(name)
}, { navigator: { clipboard: { writeText: async text => { copied.push(text) } } } })
const state = scope.run(() => component.default.setup(props, { expose() {} }))
async function html() {
const app = vue.createSSRApp({ render: () => render({}, [], props, vue.proxyRefs(state)) })
app.directive('loading', { getSSRProps: () => ({}) })
for (const name of ['el-button', 'el-icon', 'el-pagination']) app.component(name, { setup: (_, { slots }) => () => vue.h('span', slots.default?.()) })
app.component('el-tag', { props: ['type'], setup: (props, { slots }) => () => vue.h('span', { 'data-type': props.type }, slots.default?.()) })
app.component('el-empty', { props: ['description'], setup: props => () => vue.h('p', { class: 'empty-description' }, props.description) })
app.component('el-alert', { props: ['title'], setup: props => () => vue.h('p', { role: 'alert' }, props.title) })
app.component('el-input', { setup: () => () => vue.h('input') })
return renderToString(app)
}
return { props, state, data, copied, notices, html, dispose: () => scope.stop() }
}
test('the real panel selects one call, copies only its complete text, and escapes transcript HTML', async () => {
const f = panelFixture([row(12, 101, ''), row(11, 101, '医生:<img src=x onerror=alert(1)>\n患者:本次回答'), row(10), row(99, 202, '串诊单文字')])
assert.equal(f.state.selectedId.value, 11)
let html = await f.html()
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/)
assert.doesNotMatch(html, /<img src=x|串诊单文字/)
assert.doesNotMatch(html.match(/<pre[^>]*>([\s\S]*?)<\/pre>/)[1], /记录10/)
await f.state.copyTranscript()
assert.deepEqual(f.copied, ['医生:<img src=x onerror=alert(1)>\n患者:本次回答'])
f.state.selectedId.value = 10
assert.equal(f.state.selectedText.value, row(10).transcript_text)
f.data.records.value = [row(13), row(11), row(10)]
assert.equal(f.state.selectedId.value, 10)
f.state.search.value = '记录13'
assert.equal(f.state.selectedId.value, 13)
assert.equal(f.state.filteredRecords.value.length, 1)
f.props.diagnosisId = 202
assert.equal(f.state.selectedText.value, '')
assert.doesNotMatch(await f.html(), /记录13的问题|本次回答/)
f.dispose()
})
test('empty, failed and partial transcription states remain explicit in the rendered panel', async () => {
for (const status of ['running', 'partial', 'failed']) {
const f = panelFixture([{ ...row(1), transcription_status: status }])
const html = await f.html()
assert.match(html, /已保存|刷新查看最新/)
assert.match(html, /记录1的问题/)
f.dispose()
}
const missing = panelFixture([row(1, 101, '')])
assert.match(await missing.html(), /本次面诊暂无已保存的文字对话/)
missing.dispose()
const none = panelFixture([])
assert.match(await none.html(), /该诊单暂无面诊通话记录/)
none.dispose()
const failed = panelFixture([], { error: vue.ref('无权访问该诊单') })
assert.match(await failed.html(), /无权访问该诊单/)
assert.doesNotMatch(await failed.html(), /该诊单暂无面诊通话记录/)
failed.dispose()
})
test('the new tab is permission gated, lazy and absent when the drawer is closed', async () => {
const { descriptor } = parse(read('views/tcm/diagnosis/edit.vue'))
const editScript = compileScript(descriptor, { id: 'diagnosis-edit-transcript' })
const pane = descriptor.template.content.match(/<el-tab-pane\s+v-if="hasCallTranscriptPermission"[\s\S]*?<\/el-tab-pane>/)[0]
const compiled = compileTemplate({ source: pane, filename: 'edit-transcript-tab.vue', id: 'edit-transcript', compilerOptions: { bindingMetadata: editScript.bindings } })
assert.deepEqual(compiled.errors, [])
const renderPane = moduleFrom(compiled.code).render
async function html(allowed, active, visible, id) {
const state = { hasCallTranscriptPermission: allowed, activeTab: active, visible, formData: { id }, ConsultationTranscriptPanel: { props: ['diagnosisId'], setup: props => () => vue.h('div', { 'data-diagnosis': props.diagnosisId }, '真实转写面板入口') } }
const app = vue.createSSRApp({ render: () => renderPane({}, [], {}, state) })
app.component('el-tab-pane', { props: ['disabled', 'label'], setup: (props, { slots }) => () => vue.h('section', { 'data-disabled': String(props.disabled) }, [props.label, slots.default?.()]) })
app.component('el-empty', { props: ['description'], setup: props => () => vue.h('p', props.description) })
return renderToString(app)
}
assert.doesNotMatch(await html(false, 'callTranscript', true, 101), /面诊文字对话/)
assert.doesNotMatch(await html(true, 'basic', true, 101), /真实转写面板入口/)
assert.doesNotMatch(await html(true, 'callTranscript', false, 101), /真实转写面板入口/)
assert.match(await html(true, 'callTranscript', true, 101), /data-diagnosis="101"/)
assert.match(await html(true, 'callTranscript', true, ''), /data-disabled="true"/)
})
test('the read helper sends the exact diagnosis and abort signal without canceling replay requests', async () => {
let request
const api = moduleFrom(read('api/tcm.ts'), name => {
if (name === '@/utils/request') return { default: { get: (...args) => { request = args; return Promise.resolve([]) } } }
throw new Error(name)
})
const controller = new AbortController()
await api.getConsultationTranscripts({ diagnosis_id: 101 }, controller.signal)
assert.equal(request[0].url, '/tcm.diagnosis/getCallRecords')
assert.deepEqual(request[0].params, { diagnosis_id: 101 })
assert.equal(request[0].signal, controller.signal)
assert.equal(request[1].ignoreCancelToken, true)
})