393 lines
18 KiB
JavaScript
393 lines
18 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 { 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
|
|
}
|
|
})
|