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, '医生:\n患者:本次回答'), row(10), row(99, 202, '串诊单文字')]) assert.equal(f.state.selectedId.value, 11) let html = await f.html() assert.match(html, /<img src=x onerror=alert\(1\)>/) assert.doesNotMatch(html, /]*>([\s\S]*?)<\/pre>/)[1], /记录10/) await f.state.copyTranscript() assert.deepEqual(f.copied, ['医生:\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(//)[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) })