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 { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc') function loadTs(filename, dependencies = require) { const source = fs.readFileSync(filename, 'utf8') const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', compiled)(dependencies, module, module.exports) return module.exports } const { createImChatHistory } = loadTs(path.join(__dirname, '../src/utils/im-chat-history.ts')) const archive = (id, text = '已归档消息') => ({ lists: [{ msg_id: id, text }], patient_im_id: `patient_${id}`, patient_name: `患者${id}` }) const progress = (completed, extra = {}) => ({ completed, sync_token: 'session-token', inserted: 2, processed_peers: 1, total_peers: 2, ...extra }) const deferred = () => { let resolve, reject const promise = new Promise((yes, no) => { resolve = yes; reject = no }) return { promise, resolve, reject } } const settle = async () => { for (let i = 0; i < 50; i++) await Promise.resolve() } function fixture(overrides = {}) { let time = 100000, nextTimer = 0 const timers = new Map(), calls = [], notices = [] const controller = createImChatHistory({ load: async (id) => { calls.push(['load', id]); return overrides.load ? overrides.load(id) : archive(id) }, sync: async (id, token) => { calls.push(['sync', id, token]); return overrides.sync ? overrides.sync(id, token) : progress(true) }, notify: (kind, message) => notices.push({ kind, message }), now: () => time, setTimer: (callback, delay) => { const id = ++nextTimer; timers.set(id, { callback, at: time + delay }); return id }, clearTimer: (id) => timers.delete(id), visible: overrides.visible }) return { controller, state: controller.state, calls, notices, timers, setTime(value) { time = value }, advance(ms) { time += ms for (const [id, timer] of [...timers]) { if (timer.at <= time) { timers.delete(id); timer.callback() } } } } } test('archive appears immediately while automatic cloud sync is pending, then refreshes on completion', async () => { const pending = deferred() let reads = 0 const f = fixture({ load: async () => archive(++reads), sync: () => pending.promise }) f.controller.setDiagnosis(10) await settle() assert.equal(f.state.rows[0].msg_id, 1) assert.equal(f.state.syncing, true) assert.equal(f.state.loading, false) assert.equal(f.timers.size, 0) pending.resolve(progress(true)) await settle() assert.equal(f.state.rows[0].msg_id, 2) assert.equal(f.state.syncing, false) assert.equal(f.timers.size, 1) assert.deepEqual(f.notices, []) f.controller.dispose() }) test('account verification progress and skipped staff are displayed separately from sync failures', async () => { const checked = deferred(), finished = deferred() let step = 0 const f = fixture({ sync: async () => { step++ if (step === 1) return progress(false, { phase: 'checking_accounts', checked_accounts: 100, candidate_accounts: 185, skipped_accounts: 98 }) if (step === 2) return checked.promise return finished.promise } }) f.controller.setDiagnosis(1) await settle() assert.equal(f.state.phase, 'checking_accounts') assert.equal(f.state.checkedAccounts, 100) assert.equal(f.state.candidateAccounts, 185) assert.deepEqual(f.state.partialErrors, []) checked.resolve(progress(false, { phase: 'syncing', checked_accounts: 185, candidate_accounts: 185, skipped_accounts: 183, total_peers: 1 })) await settle() assert.equal(f.state.totalPeers, 1) assert.equal(f.state.skippedAccounts, 183) finished.resolve(progress(true, { phase: 'completed', skipped_accounts: 183, total_peers: 1 })) await settle() assert.equal(f.state.syncError, '') assert.deepEqual(f.state.partialErrors, []) f.controller.setDiagnosis(2) assert.equal(f.state.skippedAccounts, 0) f.controller.dispose() }) test('completion waits for initial archive read and starts a fresh read after it', async () => { const initial = deferred() let reads = 0 const f = fixture({ load: () => ++reads === 1 ? initial.promise : Promise.resolve(archive(2)) }) f.controller.setDiagnosis(10) await settle() assert.equal(reads, 1) initial.resolve(archive(1)) await settle() assert.equal(reads, 2) assert.equal(f.state.rows[0].msg_id, 2) assert.equal(f.timers.size, 1) f.controller.dispose() }) test('sync tokens continue across pages and archive refreshes every three pages and on completion', async () => { const lastPage = deferred() let pages = 0, reads = 0 const f = fixture({ load: async () => archive(++reads), sync: async () => ++pages < 4 ? progress(false, { sync_token: `token-${pages}`, inserted: pages }) : lastPage.promise }) f.controller.setDiagnosis(10) await settle() assert.equal(pages, 4) assert.equal(reads, 2) assert.deepEqual(f.calls.filter(call => call[0] === 'sync').map(call => call[2]), [undefined, 'token-1', 'token-2', 'token-3']) assert.equal(f.state.inserted, 3) lastPage.resolve(progress(true, { inserted: 4, processed_peers: 2 })) await settle() assert.equal(reads, 3) assert.equal(f.state.inserted, 4) f.controller.dispose() }) test('a slow page refreshes archive after two seconds even before three pages', async () => { const lastPage = deferred() let pages = 0, reads = 0 const f = fixture({ load: async () => archive(++reads), sync: async () => { if (++pages === 1) { f.setTime(102100); return progress(false) } return lastPage.promise } }) f.controller.setDiagnosis(10) await settle() assert.equal(reads, 2) assert.equal(f.state.syncing, true) f.controller.dispose() lastPage.resolve(progress(true)) await settle() }) test('switching diagnosis ignores old archive, old sync errors, and old completion timers', async () => { const oldRead = deferred(), oldSync = deferred() const f = fixture({ load: (id) => id === 1 ? oldRead.promise : Promise.resolve(archive(id)), sync: (id) => id === 1 ? oldSync.promise : Promise.resolve(progress(true)) }) f.controller.setDiagnosis(1) await settle() f.controller.setDiagnosis(2) await settle() oldRead.resolve(archive(1)) oldSync.reject(new Error('旧患者同步失败')) await settle() assert.equal(f.state.patientName, '患者2') assert.equal(f.state.rows[0].msg_id, 2) assert.equal(f.state.syncError, '') assert.equal(f.timers.size, 1) assert.equal(f.calls.filter(call => call[0] === 'load' && call[1] === 1).length, 1) f.controller.dispose() }) test('dispose invalidates in-flight requests and never refreshes or schedules after completion', async () => { const pending = deferred() const f = fixture({ sync: () => pending.promise }) f.controller.setDiagnosis(1) await settle() const before = f.calls.length f.controller.dispose() pending.resolve(progress(true)) await settle() assert.equal(f.calls.length, before) assert.equal(f.timers.size, 0) assert.equal(f.state.syncing, false) f.controller.setVisible(true) f.controller.setDiagnosis(2) assert.equal(f.calls.length, before) }) test('read and sync failures preserve existing rows and expose the original errors', async () => { let readFails = false, syncFails = false const f = fixture({ load: async () => { if (readFails) throw { response: { data: { msg: '归档服务读取失败' } } }; return archive(1) }, sync: async () => { if (syncFails) throw new Error('腾讯云凭证无效'); return progress(true) } }) f.controller.setDiagnosis(1) await settle() readFails = true await f.controller.reload() assert.equal(f.state.rows[0].msg_id, 1) assert.equal(f.state.readError, '归档服务读取失败') syncFails = true await f.controller.sync() assert.equal(f.state.rows[0].msg_id, 1) assert.equal(f.state.syncError, '腾讯云凭证无效') assert.deepEqual(f.notices, [{ kind: 'error', message: '腾讯云凭证无效' }]) assert.equal(f.timers.size, 1) f.controller.dispose() }) test('only a manual completed sync emits success; concurrent clicks share the in-flight run', async () => { let pending const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) }) f.controller.setDiagnosis(1) await settle() assert.deepEqual(f.notices, []) pending = deferred() const manual = f.controller.sync() const duplicate = f.controller.sync() await settle() assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2) assert.deepEqual(f.notices, []) pending.resolve(progress(true, { inserted: 7 })) await Promise.all([manual, duplicate]) assert.deepEqual(f.notices, [{ kind: 'success', message: '聊天记录已更新,本次新增 7 条' }]) assert.equal(f.timers.size, 1) f.controller.dispose() }) test('completed partial failure refreshes rows, ends the loop, and never reports full success', async () => { let partial = false, reads = 0 const f = fixture({ load: async () => archive(++reads), sync: async () => progress(true, partial ? { error: '医生账号失败', errors: ['医生账号失败', '医助账号失败'] } : {}) }) f.controller.setDiagnosis(1) await settle() partial = true await f.controller.sync() assert.deepEqual([...f.state.partialErrors], ['医生账号失败', '医助账号失败']) assert.equal(f.state.rows[0].msg_id, 3) assert.equal(f.state.syncing, false) assert.equal(f.notices.length, 1) assert.equal(f.notices[0].kind, 'warning') assert.match(f.notices[0].message, /医生账号失败;医助账号失败/) f.controller.dispose() }) test('missing token and missing completed state stop safely without claiming success', async () => { for (const response of [{ completed: false }, { inserted: 0 }]) { const f = fixture({ sync: async () => response }) f.controller.setDiagnosis(1) await settle() assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1) assert.match(f.state.syncError, /同步接口未返回/) assert.equal(f.state.rows[0].msg_id, 1) assert.equal(f.notices.length, 0) f.controller.dispose() } }) test('one 30-second timer starts only after completion and never overlaps a pending run', async () => { let pending const f = fixture({ sync: () => pending ? pending.promise : Promise.resolve(progress(true)) }) f.controller.setDiagnosis(1) await settle() f.advance(29999) await settle() assert.equal(f.calls.filter(call => call[0] === 'sync').length, 1) pending = deferred() f.advance(1) await settle() assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2) assert.equal(f.timers.size, 0) f.advance(90000) await settle() assert.equal(f.calls.filter(call => call[0] === 'sync').length, 2) pending.resolve(progress(true)) await settle() assert.equal(f.timers.size, 1) f.controller.dispose() assert.equal(f.timers.size, 0) }) test('hidden or deactivated panels stop scheduling and resume with existing rows retained', async () => { const f = fixture({ visible: false }) f.controller.setDiagnosis(1) await settle() assert.deepEqual(f.calls, []) f.controller.setVisible(true) await settle() assert.equal(f.state.rows[0].msg_id, 1) f.controller.setVisible(false) assert.equal(f.timers.size, 0) const before = f.calls.length f.advance(60000) await settle() assert.equal(f.calls.length, before) f.controller.setVisible(true) assert.equal(f.state.rows[0].msg_id, 1) await settle() assert.equal(f.timers.size, 1) f.controller.dispose() }) test('IM API functions retain raw backend reasons, send progress tokens, and disable retries', async () => { const calls = [] let reply = { code: 1, data: progress(true), msg: '不应自动显示的成功提示', show: 1 } const request = {} for (const method of ['get', 'post']) request[method] = async (config, options) => { calls.push({ method, config, options }); return reply } const api = loadTs(path.join(__dirname, '../src/api/tcm.ts'), (name) => { if (name === '@/utils/request') return { default: request } throw new Error(`Unexpected import: ${name}`) }) assert.equal((await api.triggerImChatSync({ diagnosis_id: 2, sync_token: 'resume' })).completed, true) reply = { code: 1, data: archive(2) } assert.equal((await api.getImChatMessages({ diagnosis_id: 2, only_archived: 1 })).lists[0].msg_id, 2) assert.deepEqual(calls[0].config.data, { diagnosis_id: 2, sync_token: 'resume' }) for (const call of calls) { assert.equal(call.config.timeout, 30000) assert.deepEqual(call.options, { isTransformResponse: false, ignoreCancelToken: true, isOpenRetry: false, retryCount: 0 }) } reply = { code: 0, data: [], msg: '腾讯云错误:签名校验失败' } await assert.rejects(api.triggerImChatSync({ diagnosis_id: 2 }), /腾讯云错误:签名校验失败/) }) test('chat panel script, template, and scoped styles compile', () => { const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue') const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename }) assert.deepEqual(errors, []) const script = compileScript(descriptor, { id: 'im-chat-history' }) assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: 'im-chat-history', compilerOptions: { bindingMetadata: script.bindings } }).errors, []) assert.deepEqual(compileStyle({ source: descriptor.styles[0].content, filename, id: 'im-chat-history', scoped: true, preprocessLang: 'scss' }).errors, []) }) test('actual panel setup wires archived reads, continued sync, patient switches, and lifecycle cleanup', async () => { const vue = require('vue') const hooks = {}, listeners = new Map(), timers = new Map(), apiCalls = [] const pending = deferred() let timerId = 0 const originalDocument = global.document global.document = { visibilityState: 'visible', addEventListener: (name, callback) => listeners.set(name, callback), removeEventListener: (name) => listeners.delete(name) } const scope = vue.effectScope() try { const filename = path.join(__dirname, '../src/views/tcm/diagnosis/components/ImChatRecordPanel.vue') const { descriptor } = parse(fs.readFileSync(filename, 'utf8'), { filename }) const script = compileScript(descriptor, { id: 'im-chat-panel-setup' }) const compiled = ts.transpileModule(script.content, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', compiled)((name) => { if (name === 'vue') return { ...vue, ...Object.fromEntries(['onMounted', 'onBeforeUnmount', 'onActivated', 'onDeactivated'].map(hook => [hook, callback => { hooks[hook] = callback }])) } if (name === 'dayjs') return { default: require('dayjs') } if (name === '@element-plus/icons-vue') return {} if (name === 'element-plus') return { ElMessage: { success() {}, warning() {}, error() {} } } if (name === '@/utils/im-business-message-parse') return { parseImBusinessPayload: () => null } if (name === '@/utils/im-chat-history') return { createImChatHistory: (deps) => createImChatHistory({ ...deps, setTimer: callback => { const id = ++timerId; timers.set(id, callback); return id }, clearTimer: id => timers.delete(id) }) } if (name === '@/api/tcm') return { getImChatMessages: async params => { apiCalls.push(['read', params]); return archive(params.diagnosis_id) }, triggerImChatSync: async params => { apiCalls.push(['sync', params]); return params.diagnosis_id === 1 ? pending.promise : progress(true) } } throw new Error(`Unexpected panel import: ${name}`) }, module, module.exports) const props = vue.reactive({ diagnosisId: 1 }) const panel = scope.run(() => module.exports.default.setup(props, { expose() {} })) hooks.onMounted() await settle() assert.equal(panel.rows.value[0].raw.msg_id, 1) assert.deepEqual(apiCalls[0], ['read', { diagnosis_id: 1, only_archived: 1 }]) assert.deepEqual(apiCalls[1], ['sync', { diagnosis_id: 1 }]) props.diagnosisId = 2 await settle() assert.equal(panel.rows.value[0].raw.msg_id, 2) pending.resolve(progress(true)) await settle() assert.equal(panel.rows.value[0].raw.msg_id, 2) assert.equal(timers.size, 1) panel.history.rows = [{ msg_id: 'multi-part', msg_type: 'composite', from_account: 'doctor_20', to_account: 'patient_2', time: 1700000000, parts: [{ msg_type: 'text', text: '第一段' }, { msg_type: 'image', image_url: 'https://example.invalid/chat.png' }, { msg_type: 'text', text: '第三段' }] }] assert.deepEqual(panel.rows.value.map(item => [item.raw.msg_id, item.raw.msg_type]), [ ['multi-part:0', 'text'], ['multi-part:1', 'image'], ['multi-part:2', 'text'] ]) assert.equal(panel.rows.value[2].raw.text, '第三段') assert.ok(panel.rows.value.every(item => item.raw.to_account === 'patient_2')) global.document.visibilityState = 'hidden' listeners.get('visibilitychange')() assert.equal(timers.size, 0) hooks.onBeforeUnmount() assert.equal(listeners.size, 0) } finally { scope.stop() global.document = originalDocument } })