2260 lines
77 KiB
JavaScript
2260 lines
77 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, compileStyle } = require('@vue/compiler-sfc')
|
|
const base = path.join(__dirname, '../src/views/assistant_service')
|
|
const compile = (source) =>
|
|
ts.transpileModule(source, {
|
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
|
}).outputText
|
|
function evaluate(source, dependencies = require) {
|
|
const module = { exports: {} }
|
|
new Function('require', 'module', 'exports', compile(source))(
|
|
dependencies,
|
|
module,
|
|
module.exports
|
|
)
|
|
return module.exports
|
|
}
|
|
const shared = evaluate(fs.readFileSync(path.join(base, 'shared.ts'), 'utf8'))
|
|
const { createJobTracker } = evaluate(
|
|
fs.readFileSync(path.join(base, 'workbench/jobTracker.ts'), 'utf8'),
|
|
(name) => (name === 'vue' ? vue : shared)
|
|
)
|
|
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()
|
|
}
|
|
const job = (id, diagnosis = 1, status = 'queued', type = 'analysis') => ({
|
|
id,
|
|
subject_admin_id: 8,
|
|
diagnosis_id: diagnosis,
|
|
status,
|
|
type,
|
|
version: 1,
|
|
scenario: 'followup',
|
|
stale: false,
|
|
created_at: 1700000000,
|
|
updated_at: 1700000000,
|
|
error_code: '',
|
|
error_message: '',
|
|
coverage: [],
|
|
sources: [],
|
|
reviews: [],
|
|
result: null
|
|
})
|
|
const analysis = () => ({
|
|
summary: [],
|
|
needs: [],
|
|
unresolved: [],
|
|
tags: [],
|
|
suggested_actions: [],
|
|
quality_checks: []
|
|
})
|
|
const capabilities = {
|
|
enabled: true,
|
|
ready: true,
|
|
schema_ready: true,
|
|
reason: '',
|
|
permissions: [
|
|
'view',
|
|
'analyze',
|
|
'draftReply',
|
|
'record',
|
|
'adoptAction',
|
|
'completeAction',
|
|
'reviewQuality',
|
|
'manageKnowledge'
|
|
],
|
|
scenarios: [{ value: 'followup', label: '随访' }],
|
|
limits: { max_manual_chars: 10000, max_source_chars: 30000 }
|
|
}
|
|
const context = (id, subject = 8) => ({
|
|
subject_admin_id: subject,
|
|
patient: { diagnosis_id: id, patient_id: id, patient_name: `合成患者${id}`, assistant_id: 8 },
|
|
sources: [
|
|
{
|
|
id: `manual:${id}`,
|
|
text: '合成来源',
|
|
channel: 'manual',
|
|
speaker: 'unknown',
|
|
actor_admin_id: 0,
|
|
quality_eligible: false,
|
|
occurred_at: 1700000000
|
|
}
|
|
],
|
|
coverage: [],
|
|
latest_job: null,
|
|
latest_analysis_job: null,
|
|
days: 30
|
|
})
|
|
const page = (rows) => ({ lists: rows, count: rows.length, page_no: 1, page_size: 10 })
|
|
function trackerFixture(read) {
|
|
let time = 0,
|
|
id = 0
|
|
const timers = new Map()
|
|
const tracker = createJobTracker({
|
|
read,
|
|
now: () => time,
|
|
setTimer: (fn) => {
|
|
timers.set(++id, fn)
|
|
return id
|
|
},
|
|
clearTimer: (id) => timers.delete(id)
|
|
})
|
|
return {
|
|
tracker,
|
|
timers,
|
|
advance(ms) {
|
|
time += ms
|
|
const callbacks = [...timers.values()]
|
|
timers.clear()
|
|
callbacks.forEach((fn) => fn())
|
|
}
|
|
}
|
|
}
|
|
function setup(relative, props = {}, api = {}, extra = {}) {
|
|
const hooks = {},
|
|
calls = [],
|
|
events = [],
|
|
scope = vue.effectScope()
|
|
const filename = path.join(base, relative)
|
|
const { descriptor } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
|
const compiled = compileScript(descriptor, { id: relative })
|
|
const component = evaluate(compiled.content, (name) => {
|
|
if (name === 'vue')
|
|
return {
|
|
...vue,
|
|
useModel: (props, key) => vue.ref(props[key]),
|
|
...Object.fromEntries(
|
|
['onMounted', 'onBeforeUnmount', 'onActivated', 'onDeactivated'].map((hook) => [
|
|
hook,
|
|
(fn) => {
|
|
hooks[hook] = fn
|
|
}
|
|
])
|
|
)
|
|
}
|
|
if (name === '@/api/assistantService') return api
|
|
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 8 } }) }
|
|
if (name === '@/utils/feedback')
|
|
return {
|
|
default: {
|
|
msgSuccess: (message) => calls.push(['success', message]),
|
|
msgWarning: (message) => calls.push(['warning', message]),
|
|
msgError: (message) => calls.push(['error', message]),
|
|
confirm: async (message) => {
|
|
calls.push(['confirm', message])
|
|
return true
|
|
},
|
|
...extra.feedback
|
|
}
|
|
}
|
|
if (name === 'vue-clipboard3')
|
|
return {
|
|
default: () => ({
|
|
toClipboard: async (text) => {
|
|
calls.push(['copy', text])
|
|
}
|
|
})
|
|
}
|
|
if (name.endsWith('/shared') || name === '../shared') return shared
|
|
if (name === './jobTracker' || name === '../workbench/jobTracker')
|
|
return { createJobTracker: extra.tracker || createJobTracker }
|
|
if (name.endsWith('.vue')) return { default: {} }
|
|
throw new Error(`Unexpected import ${name}`)
|
|
}).default
|
|
const reactiveProps = vue.reactive(props)
|
|
const panel = scope.run(() =>
|
|
component.setup(reactiveProps, { expose() {}, emit: (...args) => events.push(args) })
|
|
)
|
|
return {
|
|
panel,
|
|
props: reactiveProps,
|
|
hooks,
|
|
calls,
|
|
events,
|
|
dispose() {
|
|
hooks.onBeforeUnmount?.()
|
|
scope.stop()
|
|
}
|
|
}
|
|
}
|
|
|
|
test('all module SFC scripts, templates and scoped styles compile', () => {
|
|
const walk = (directory) =>
|
|
fs
|
|
.readdirSync(directory, { withFileTypes: true })
|
|
.flatMap((entry) =>
|
|
entry.isDirectory()
|
|
? walk(path.join(directory, entry.name))
|
|
: [path.join(directory, entry.name)]
|
|
)
|
|
for (const filename of walk(base).filter((name) => name.endsWith('.vue'))) {
|
|
const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename })
|
|
assert.deepEqual(errors, [])
|
|
const script = compileScript(descriptor, { id: filename })
|
|
const template = compileTemplate({
|
|
source: descriptor.template.content,
|
|
filename,
|
|
id: filename,
|
|
compilerOptions: { bindingMetadata: script.bindings }
|
|
})
|
|
assert.deepEqual(template.errors, [], filename)
|
|
for (const style of descriptor.styles)
|
|
assert.deepEqual(
|
|
compileStyle({ source: style.content, filename, id: filename, scoped: true })
|
|
.errors,
|
|
[]
|
|
)
|
|
}
|
|
})
|
|
|
|
test('API retains error reason/code and disables automatic retries and URL cancellation for mutations', async () => {
|
|
const calls = []
|
|
let response = { code: 1, data: job(1) }
|
|
const request = Object.fromEntries(
|
|
['get', 'post'].map((method) => [
|
|
method,
|
|
async (config, options) => {
|
|
calls.push({ method, config, options })
|
|
return response
|
|
}
|
|
])
|
|
)
|
|
const api = evaluate(
|
|
fs.readFileSync(path.join(__dirname, '../src/api/assistantService.ts'), 'utf8'),
|
|
() => ({ default: request })
|
|
)
|
|
await api.assistantAnalyze({ diagnosis_id: 1, days: 30, scenario: 'followup' })
|
|
await api.assistantDraftReply({
|
|
diagnosis_id: 1,
|
|
days: 30,
|
|
scenario: 'followup',
|
|
question: '合成问题'
|
|
})
|
|
for (const call of calls) {
|
|
assert.equal(call.options.isOpenRetry, false)
|
|
assert.equal(call.options.ignoreCancelToken, true)
|
|
assert.equal(call.options.retryCount, 0)
|
|
assert.equal(call.config.timeout, 30000)
|
|
assert.equal(call.config.data.diagnosis_id, 1)
|
|
}
|
|
assert.equal(calls[0].config.url, '/tcm.assistantService/analyze')
|
|
response = {
|
|
code: 0,
|
|
msg: '服务知识版本已被更新,请刷新后重试',
|
|
data: { code: 'version_conflict' }
|
|
}
|
|
await assert.rejects(
|
|
api.assistantSaveKnowledge({
|
|
id: 1,
|
|
version: 1,
|
|
question: '问题',
|
|
answer: '答案',
|
|
category: '',
|
|
source_note: '核对'
|
|
}),
|
|
(error) => error.code === 'version_conflict' && error.message.includes('版本')
|
|
)
|
|
})
|
|
|
|
test('job tracker discards prior patient responses and disposes timers', async () => {
|
|
const old = deferred(),
|
|
f = trackerFixture((id) => (id === 1 ? old.promise : Promise.resolve(job(id, 2, 'ready'))))
|
|
f.tracker.setJob(job(1))
|
|
f.advance(3000)
|
|
await settle()
|
|
f.tracker.setJob(job(2, 2))
|
|
old.resolve(job(1, 1, 'ready'))
|
|
await settle()
|
|
assert.equal(f.tracker.state.job.id, 2)
|
|
f.advance(3000)
|
|
await settle()
|
|
assert.equal(f.tracker.state.job.status, 'ready')
|
|
assert.equal(f.timers.size, 0)
|
|
f.tracker.setJob(job(3))
|
|
assert.equal(f.timers.size, 1)
|
|
f.tracker.dispose()
|
|
assert.equal(f.timers.size, 0)
|
|
assert.equal(f.tracker.state.job, null)
|
|
})
|
|
|
|
test('poll timeout pauses reads; visibility pause and failed authorization hide actionable results', async () => {
|
|
let calls = 0,
|
|
fail = false
|
|
const f = trackerFixture(async (id) => {
|
|
calls++
|
|
if (fail) throw new Error('患者权限已撤销')
|
|
return job(id)
|
|
})
|
|
f.tracker.setJob(job(1))
|
|
f.advance(360000)
|
|
await settle()
|
|
assert.equal(calls, 1)
|
|
assert.equal(f.tracker.state.paused, true)
|
|
assert.match(f.tracker.state.error, /6 分钟/)
|
|
assert.equal(f.timers.size, 0)
|
|
f.tracker.setActive(false)
|
|
f.tracker.setActive(true)
|
|
await settle()
|
|
assert.equal(calls, 2)
|
|
f.tracker.setJob({ ...job(1, 1, 'ready'), result: analysis() })
|
|
fail = true
|
|
await f.tracker.refresh()
|
|
assert.equal(f.tracker.state.job.result, null)
|
|
assert.match(f.tracker.state.error, /权限已撤销/)
|
|
f.tracker.dispose()
|
|
})
|
|
|
|
test('actual case detail preserves stale context/submission protection and duplicate generation guard', async () => {
|
|
const originalDocument = global.document,
|
|
listeners = new Map(),
|
|
old = deferred(),
|
|
submit = deferred(),
|
|
submissions = []
|
|
global.document = {
|
|
visibilityState: 'visible',
|
|
addEventListener: (name, fn) => listeners.set(name, fn),
|
|
removeEventListener: (name) => listeners.delete(name)
|
|
}
|
|
const f = setup(
|
|
'components/CaseDetail.vue',
|
|
{ patient: context(1).patient, assistantId: 8, days: 30, capabilities },
|
|
{
|
|
assistantCapabilities: async () => capabilities,
|
|
assistantContext: async (params) =>
|
|
params.diagnosis_id === 1 ? old.promise : context(params.diagnosis_id),
|
|
assistantJobs: async () => page([]),
|
|
assistantJob: async (id) => job(id, 2, 'ready'),
|
|
assistantAnalyze: async (data) => {
|
|
submissions.push(data)
|
|
return submit.promise
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
f.panel.selectPatient(context(1).patient)
|
|
f.panel.selectPatient(context(2).patient)
|
|
await settle()
|
|
old.resolve(context(1))
|
|
await settle()
|
|
assert.equal(f.panel.context.value.patient.diagnosis_id, 2)
|
|
const first = f.panel.generate('analysis'),
|
|
duplicate = f.panel.generate('analysis')
|
|
await settle()
|
|
assert.equal(submissions.length, 1)
|
|
f.panel.selectPatient(context(3).patient)
|
|
await settle()
|
|
submit.resolve(job(10, 2, 'ready'))
|
|
await Promise.all([first, duplicate])
|
|
assert.equal(f.panel.analysisState.job, null)
|
|
assert.equal(f.panel.selected.value.diagnosis_id, 3)
|
|
} finally {
|
|
f.dispose()
|
|
assert.equal(listeners.size, 0)
|
|
global.document = originalDocument
|
|
}
|
|
})
|
|
|
|
test('actual record dialog requires explicit transcript confirmation and guards double submit', async () => {
|
|
const pending = deferred(),
|
|
calls = []
|
|
const f = setup(
|
|
'components/RecordDialog.vue',
|
|
{ diagnosisId: 2, maxChars: 10000, modelValue: true },
|
|
{
|
|
assistantRecord: async (data) => {
|
|
calls.push(data)
|
|
return pending.promise
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
f.panel.form.record_type = 'verified_transcript'
|
|
f.panel.form.content = '合成转写'
|
|
f.panel.occurredAt.value = new Date(Date.now() - 10000)
|
|
await f.panel.save()
|
|
assert.equal(calls.length, 0)
|
|
assert.match(f.panel.error.value, /确认/)
|
|
f.panel.form.speaker_confirmed = true
|
|
const first = f.panel.save()
|
|
await f.panel.save()
|
|
assert.equal(calls.length, 1)
|
|
assert.equal(calls[0].speaker_confirmed, true)
|
|
pending.resolve({ id: 5 })
|
|
await first
|
|
assert.equal(f.events[0][0], 'saved')
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('actual result component disables stale adoption/copy and locates generation snapshot evidence', async () => {
|
|
const evidence = context(1).sources[0],
|
|
calls = []
|
|
const item = { key: 'call', text: '合成跟进事项', evidence_ids: [evidence.id] }
|
|
const f = setup(
|
|
'components/JobResult.vue',
|
|
{
|
|
job: {
|
|
...job(1, 1, 'ready'),
|
|
sources: [evidence],
|
|
result: { ...analysis(), suggested_actions: [item] }
|
|
},
|
|
permissions: ['adoptAction'],
|
|
checking: false,
|
|
error: '',
|
|
disabled: false
|
|
},
|
|
{
|
|
assistantAdoptAction: async (data) => {
|
|
calls.push(data)
|
|
return { id: 9, todo_id: 10 }
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
f.panel.evidence([evidence.id])
|
|
assert.equal(f.events[0][1][0].text, '合成来源')
|
|
f.panel.openAdopt(item)
|
|
await f.panel.adopt()
|
|
assert.equal(calls.length, 0)
|
|
assert.match(f.panel.mutationError.value, /截止时间/)
|
|
f.panel.dueAt.value = new Date(Date.now() + 60000)
|
|
f.props.job.stale = true
|
|
await f.panel.adopt()
|
|
assert.equal(calls.length, 0)
|
|
f.props.job.stale = false
|
|
await f.panel.adopt()
|
|
assert.equal(calls.length, 1)
|
|
assert.equal(calls[0].version, 1)
|
|
assert.equal(calls[0].action_key, 'call')
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('actual service task component separates notification from completion and enforces owner/terminal notes', async () => {
|
|
const calls = [],
|
|
row = {
|
|
id: 1,
|
|
diagnosis_id: 2,
|
|
content: '合成跟进',
|
|
business_state: 'pending',
|
|
notification_status: 1,
|
|
assignee_id: 8,
|
|
due_at: 1900000000
|
|
}
|
|
const f = setup(
|
|
'components/ServiceActions.vue',
|
|
{ diagnosisId: 2, actorId: 8, canComplete: true, canReview: false, refreshKey: 0 },
|
|
{
|
|
assistantActions: async () => page([row]),
|
|
assistantCompleteAction: async (data) => {
|
|
calls.push(data)
|
|
return { id: 1 }
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
await settle()
|
|
assert.equal(f.panel.notificationLabel(1), '已发送')
|
|
assert.equal(f.panel.canUpdate(row), true)
|
|
assert.equal(f.panel.canUpdate({ ...row, assignee_id: 9 }), false)
|
|
assert.equal(f.panel.canUpdate({ ...row, business_state: 'completed' }), false)
|
|
f.panel.edit(row)
|
|
f.panel.nextState.value = 'completed'
|
|
await f.panel.save()
|
|
assert.equal(calls.length, 0)
|
|
f.panel.note.value = '已电话核对预约流程'
|
|
await f.panel.save()
|
|
assert.equal(calls[0].state, 'completed')
|
|
assert.equal(calls[0].note, '已电话核对预约流程')
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('knowledge conflict preserves local draft and reloads actual server version before publishing', async () => {
|
|
const row = {
|
|
id: 1,
|
|
version: 2,
|
|
question: '合成问题',
|
|
answer: '旧答案',
|
|
category: '测试',
|
|
source_note: '合成说明',
|
|
status: 'published',
|
|
updated_at: 1700000000
|
|
}
|
|
const latest = { ...row, version: 4, answer: '已更新答案', status: 'draft' },
|
|
writes = []
|
|
const f = setup(
|
|
'knowledge/index.vue',
|
|
{},
|
|
{
|
|
assistantCapabilities: async () => capabilities,
|
|
assistantKnowledge: async () => page([latest]),
|
|
assistantSaveKnowledge: async () => {
|
|
throw new Error('服务知识版本已被更新,请刷新后重试')
|
|
},
|
|
assistantPublishKnowledge: async (data) => {
|
|
writes.push(data)
|
|
return { id: 1, version: 5 }
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
await f.hooks.onMounted()
|
|
f.panel.openEditor(row)
|
|
f.panel.form.answer = '本地未保存答案'
|
|
await f.panel.saveDraft()
|
|
assert.equal(f.panel.conflicted.value, true)
|
|
assert.equal(f.panel.form.answer, '本地未保存答案')
|
|
assert.equal(f.panel.form.version, 2)
|
|
await f.panel.publish('published')
|
|
assert.equal(writes.length, 0)
|
|
await f.panel.copyLocal()
|
|
assert.ok(f.calls.some((call) => call[0] === 'copy' && call[1].includes('本地未保存答案')))
|
|
await f.panel.reloadLatest()
|
|
assert.equal(f.panel.form.version, 4)
|
|
assert.equal(f.panel.form.answer, '已更新答案')
|
|
assert.equal(f.panel.conflicted.value, false)
|
|
await f.panel.publish('published')
|
|
assert.deepEqual(writes[0], { id: 1, version: 4, status: 'published' })
|
|
assert.equal(f.panel.form.version, 5)
|
|
assert.ok(f.calls.some((call) => call[0] === 'confirm' && call[1].includes('发布后')))
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('actual reply editor copies edited draft and blocks copy after result becomes stale', async () => {
|
|
const f = setup('components/JobResult.vue', {
|
|
job: {
|
|
...job(7, 1, 'ready', 'reply'),
|
|
result: {
|
|
reply: '合成原始草稿',
|
|
evidence_ids: [],
|
|
knowledge_refs: [{ id: 1, version: 3 }],
|
|
needs_confirmation: true,
|
|
reason: '请核对适用条件'
|
|
}
|
|
},
|
|
permissions: ['view', 'draftReply'],
|
|
checking: false,
|
|
error: '',
|
|
disabled: false
|
|
})
|
|
try {
|
|
assert.equal(f.panel.draft.value, '合成原始草稿')
|
|
f.panel.draft.value = '人工核对后的合成草稿'
|
|
f.props.job = { ...f.props.job, result: { ...f.props.job.result } }
|
|
await settle()
|
|
assert.equal(f.panel.draft.value, '人工核对后的合成草稿')
|
|
await f.panel.copyDraft()
|
|
assert.equal(f.calls.filter((call) => call[0] === 'copy')[0][1], '人工核对后的合成草稿')
|
|
f.props.job.stale = true
|
|
await f.panel.copyDraft()
|
|
assert.equal(f.calls.filter((call) => call[0] === 'copy').length, 1)
|
|
assert.equal(f.events.length, 0)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('actual quality review requires a note and submits the original item key and version', async () => {
|
|
const calls = [],
|
|
item = {
|
|
key: 'needs',
|
|
label: '诉求核实',
|
|
status: 'insufficient',
|
|
reason: '说话人未核实',
|
|
evidence_ids: [],
|
|
actor_admin_id: 0
|
|
}
|
|
const f = setup(
|
|
'components/JobResult.vue',
|
|
{
|
|
job: { ...job(8, 1, 'ready'), result: { ...analysis(), quality_checks: [item] } },
|
|
permissions: ['reviewQuality'],
|
|
checking: false,
|
|
error: '',
|
|
disabled: false
|
|
},
|
|
{
|
|
assistantReviewQuality: async (data) => {
|
|
calls.push(data)
|
|
return { id: 2 }
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
f.panel.openReview(item)
|
|
await f.panel.review()
|
|
assert.equal(calls.length, 0)
|
|
f.panel.reviewNote.value = '经核对材料不足,维持不可评价'
|
|
f.panel.decision.value = 'confirmed'
|
|
f.props.job = { ...f.props.job, result: { ...f.props.job.result } }
|
|
await settle()
|
|
assert.equal(f.panel.reviewOpened.value, true)
|
|
assert.equal(f.panel.reviewNote.value, '经核对材料不足,维持不可评价')
|
|
assert.equal(f.panel.decision.value, 'confirmed')
|
|
await f.panel.review()
|
|
assert.deepEqual(calls[0], {
|
|
job_id: 8,
|
|
version: 1,
|
|
item_key: 'needs',
|
|
decision: 'confirmed',
|
|
note: '经核对材料不足,维持不可评价'
|
|
})
|
|
assert.ok(f.events.some((event) => event[0] === 'refresh'))
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
const staff = (id) => ({ id, name: `合成医助${id}`, is_self: id === 8 })
|
|
const staffCase = (id, subject = 8) => ({
|
|
...context(id, subject).patient,
|
|
im_messages: 3,
|
|
manual_records: 1,
|
|
last_activity_at: 1700000000
|
|
})
|
|
const overview = (id, days = 30) => ({
|
|
staff: staff(id),
|
|
days,
|
|
metrics: {
|
|
assigned_cases: id,
|
|
im_messages: 3,
|
|
manual_records: 1,
|
|
analyzed_cases: 1,
|
|
pending_actions: 2,
|
|
overdue_actions: 1,
|
|
completed_actions: 1
|
|
},
|
|
quality: [],
|
|
coverage_notes: []
|
|
})
|
|
const teamStaff = (id) => ({
|
|
staff: staff(id),
|
|
today_contact: dailyCounts(),
|
|
metrics: overview(id).metrics,
|
|
unreviewed_issues: 1,
|
|
last_analyzed_at: 1700000000,
|
|
analysis_state: 'ready',
|
|
queued_jobs: 0,
|
|
running_jobs: 0,
|
|
attention_jobs: 0
|
|
})
|
|
const teamOverview = (rows = [teamStaff(8), teamStaff(9)]) => ({
|
|
...page(rows),
|
|
page_size: 20,
|
|
days: 30,
|
|
daily: dailyMeta(),
|
|
daily_summary: dailyCounts({
|
|
required: 8,
|
|
contacted: 2,
|
|
pending: 4,
|
|
reported: 2,
|
|
outstanding: 6
|
|
}),
|
|
summary: {
|
|
staff_count: 26,
|
|
active_staff: 19,
|
|
analyzed_cases: 36,
|
|
unreviewed_issues: 5,
|
|
pending_actions: 8,
|
|
overdue_actions: 2,
|
|
queued_jobs: 0,
|
|
running_jobs: 0,
|
|
attention_jobs: 0
|
|
},
|
|
automation: {
|
|
enabled: true,
|
|
state: 'running',
|
|
last_scan_at: 1700000000,
|
|
next_scan_at: 1700000060,
|
|
interval_seconds: 60,
|
|
quiet_seconds: 300,
|
|
days: 30,
|
|
reason: ''
|
|
}
|
|
})
|
|
const dailyCounts = (overrides = {}) => ({
|
|
required: 4,
|
|
contacted: 1,
|
|
pending: 2,
|
|
reported: 1,
|
|
outstanding: 3,
|
|
exempt: 0,
|
|
state: 'pending',
|
|
...overrides
|
|
})
|
|
const dailyMeta = (overrides = {}) => ({
|
|
business_date: '2026-11-10',
|
|
timezone: 'Asia/Shanghai',
|
|
as_of: 1794268800,
|
|
state: 'ready',
|
|
reason: '',
|
|
last_scan_at: 1794268800,
|
|
rule_label: '到期复诊与明确需联系的服务跟进',
|
|
coverage: [{ source: 'im', status: 'available', note: '已归档文字' }],
|
|
...overrides
|
|
})
|
|
const dailyPatient = (id = 3, status = 'pending') => ({
|
|
key: `patient:${id}`,
|
|
patient_id: id,
|
|
patient_name: `合成患者${id}`,
|
|
diagnosis_id: id,
|
|
diagnosis_ids: [id, id + 100],
|
|
status,
|
|
reasons: [{ type: 'revisit', label: '复诊到期', due_at: 1794268800, source_id: id }],
|
|
last_contact_at: 0,
|
|
evidence: [],
|
|
exempt_reason: ''
|
|
})
|
|
const dailyContacts = (id = 9, overrides = {}) => ({
|
|
...page([dailyPatient()]),
|
|
staff: staff(id),
|
|
daily: dailyMeta(),
|
|
summary: dailyCounts(),
|
|
...overrides
|
|
})
|
|
function installTimers() {
|
|
const previousSet = global.setTimeout,
|
|
previousClear = global.clearTimeout,
|
|
timers = new Map()
|
|
let id = 0
|
|
global.setTimeout = (fn) => {
|
|
timers.set(++id, fn)
|
|
return id
|
|
}
|
|
global.clearTimeout = (value) => timers.delete(value)
|
|
const restore = () => {
|
|
global.setTimeout = previousSet
|
|
global.clearTimeout = previousClear
|
|
}
|
|
restore.timers = timers
|
|
return restore
|
|
}
|
|
function workspaceApi(overrides = {}) {
|
|
return {
|
|
assistantCapabilities: async () => capabilities,
|
|
assistantTeamOverview: async () => teamOverview(),
|
|
assistantStaff: async () => page([staff(8), staff(9)]),
|
|
assistantStaffOverview: async ({ assistant_id, days }) => overview(assistant_id, days),
|
|
assistantStaffDailyContacts: async ({ assistant_id }) => dailyContacts(assistant_id),
|
|
assistantStaffCases: async ({ assistant_id }) =>
|
|
page([staffCase(assistant_id, assistant_id)]),
|
|
assistantStaffIssues: async () => page([]),
|
|
assistantJobs: async () => page([]),
|
|
assistantJob: async (id) => ({ ...job(id, id, 'ready'), subject_admin_id: 8 }),
|
|
...overrides
|
|
}
|
|
}
|
|
function installDocument() {
|
|
const previous = global.document,
|
|
listeners = new Set()
|
|
global.document = {
|
|
visibilityState: 'visible',
|
|
addEventListener: (_, fn) => listeners.add(fn),
|
|
removeEventListener: (_, fn) => listeners.delete(fn)
|
|
}
|
|
return () => {
|
|
assert.equal(listeners.size, 0)
|
|
global.document = previous
|
|
}
|
|
}
|
|
|
|
test('staff APIs preserve explicit target scope and existing optional-target compatibility', async () => {
|
|
const calls = []
|
|
const request = Object.fromEntries(
|
|
['get', 'post'].map((method) => [
|
|
method,
|
|
async (config, options) => {
|
|
calls.push({ method, config, options })
|
|
return { code: 1, data: {} }
|
|
}
|
|
])
|
|
)
|
|
const api = evaluate(
|
|
fs.readFileSync(path.join(__dirname, '../src/api/assistantService.ts'), 'utf8'),
|
|
() => ({ default: request })
|
|
)
|
|
const scope = { assistant_id: 9, days: 7 }
|
|
await api.assistantStaff({ keyword: '医助', page_no: 1, page_size: 50 })
|
|
await api.assistantStaffOverview(scope)
|
|
await api.assistantStaffCases({ ...scope, page_no: 1, page_size: 10 })
|
|
await api.assistantStaffIssues({ ...scope, page_no: 1, page_size: 10 })
|
|
await api.assistantAnalyzeStaff({ ...scope, scenario: 'followup', diagnosis_ids: [3, 4] })
|
|
await api.assistantContext({ ...scope, diagnosis_id: 3 })
|
|
await api.assistantJobs({ assistant_id: 9, page_no: 1, page_size: 10 })
|
|
await api.assistantActions({ assistant_id: 9, page_no: 1, page_size: 10 })
|
|
await api.assistantActions({ diagnosis_id: 3, page_no: 1, page_size: 10 })
|
|
assert.deepEqual(
|
|
calls.map((call) => call.config.url.split('/').at(-1)),
|
|
[
|
|
'staff',
|
|
'staffOverview',
|
|
'staffCases',
|
|
'staffIssues',
|
|
'analyzeStaff',
|
|
'context',
|
|
'jobs',
|
|
'actions',
|
|
'actions'
|
|
]
|
|
)
|
|
for (const call of calls.slice(1, 8))
|
|
assert.equal((call.config.params || call.config.data).assistant_id, 9)
|
|
assert.deepEqual(calls[4].config.data.diagnosis_ids, [3, 4])
|
|
assert.equal(calls[4].options.isOpenRetry, false)
|
|
assert.equal(calls[8].config.params.assistant_id, undefined)
|
|
})
|
|
|
|
test('team API is a cancellable read and keeps search, filter and pagination explicit', async () => {
|
|
const calls = [],
|
|
signal = new AbortController().signal
|
|
const api = evaluate(
|
|
fs.readFileSync(path.join(__dirname, '../src/api/assistantService.ts'), 'utf8'),
|
|
() => ({
|
|
default: {
|
|
get: async (config, options) => {
|
|
calls.push({ config, options })
|
|
return { code: 1, data: teamOverview() }
|
|
}
|
|
}
|
|
})
|
|
)
|
|
const result = await api.assistantTeamOverview(
|
|
{ keyword: '医助', filter: 'attention', page_no: 2, page_size: 20 },
|
|
signal
|
|
)
|
|
assert.equal(calls[0].config.url, '/tcm.assistantService/teamOverview')
|
|
assert.equal(calls[0].config.signal, signal)
|
|
assert.equal(calls[0].options.isOpenRetry, false)
|
|
assert.deepEqual(calls[0].config.params, {
|
|
keyword: '医助',
|
|
filter: 'attention',
|
|
page_no: 2,
|
|
page_size: 20
|
|
})
|
|
assert.equal(result.summary.staff_count, 26)
|
|
assert.equal(result.lists.length, 2)
|
|
})
|
|
|
|
test('team overview reads authorized staff without selecting anyone or submitting analysis', async () => {
|
|
const restore = installDocument(),
|
|
requests = [],
|
|
writes = []
|
|
const f = setup(
|
|
'workbench/index.vue',
|
|
{},
|
|
workspaceApi({
|
|
assistantTeamOverview: async (params) => {
|
|
requests.push(params)
|
|
return teamOverview([teamStaff(9)])
|
|
},
|
|
assistantAnalyzeStaff: async (data) => writes.push(data),
|
|
assistantAnalyze: async (data) => writes.push(data)
|
|
})
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
assert.equal(f.panel.surface.value, 'alerts')
|
|
assert.equal(requests.length, 0)
|
|
await f.panel.setSurface('daily')
|
|
await settle()
|
|
assert.equal(f.panel.selectedStaff.value, null)
|
|
assert.equal(f.panel.drawerOpened.value, false)
|
|
assert.equal(f.panel.team.value.lists[0].staff.id, 9)
|
|
assert.deepEqual(requests[0], { keyword: '', filter: 'all', page_no: 1, page_size: 20 })
|
|
assert.equal(writes.length, 0)
|
|
f.panel.openStaff(teamStaff(8))
|
|
assert.equal(f.panel.drawerOpened.value, false)
|
|
f.panel.openStaff(f.panel.team.value.lists[0])
|
|
assert.equal(f.panel.selectedStaff.value.id, 9)
|
|
assert.equal(f.panel.drawerOpened.value, true)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('team search and filter cancel older reads and never mix a late response into the current scope', async () => {
|
|
const restore = installDocument(),
|
|
old = deferred(),
|
|
signals = [],
|
|
requests = []
|
|
let count = 0
|
|
const f = setup(
|
|
'workbench/index.vue',
|
|
{},
|
|
workspaceApi({
|
|
assistantTeamOverview: async (params, signal) => {
|
|
signals.push(signal)
|
|
requests.push(params)
|
|
if (++count === 1) return old.promise
|
|
return teamOverview([teamStaff(9)])
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
f.panel.setSurface('daily')
|
|
f.panel.keyword.value = '医助九'
|
|
f.panel.search()
|
|
assert.equal(signals[0].aborted, true)
|
|
await settle()
|
|
f.panel.setFilter('issues')
|
|
await settle()
|
|
old.resolve(teamOverview([teamStaff(8)]))
|
|
await settle()
|
|
assert.equal(f.panel.team.value.lists[0].staff.id, 9)
|
|
assert.equal(f.panel.filter.value, 'issues')
|
|
assert.deepEqual(requests.at(-1), {
|
|
keyword: '医助九',
|
|
filter: 'issues',
|
|
page_no: 1,
|
|
page_size: 20
|
|
})
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('automatic refresh preserves search draft, filter, page and drawer, and pauses while hidden', async () => {
|
|
const restore = installDocument(),
|
|
restoreTimers = installTimers(),
|
|
requests = []
|
|
const f = setup(
|
|
'workbench/index.vue',
|
|
{},
|
|
workspaceApi({
|
|
assistantTeamOverview: async (params) => {
|
|
requests.push(params)
|
|
return teamOverview()
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
await f.panel.setSurface('daily')
|
|
await settle()
|
|
f.panel.setFilter('attention')
|
|
await settle()
|
|
f.panel.page.value = 3
|
|
f.panel.keyword.value = '未提交的搜索'
|
|
f.panel.openStaff(f.panel.team.value.lists[0])
|
|
const current = f.panel.selectedStaff.value.id
|
|
await f.panel.refreshResults()
|
|
assert.equal(f.panel.keyword.value, '未提交的搜索')
|
|
assert.equal(f.panel.page.value, 3)
|
|
assert.equal(f.panel.filter.value, 'attention')
|
|
assert.equal(f.panel.selectedStaff.value.id, current)
|
|
assert.equal(f.panel.drawerOpened.value, true)
|
|
assert.equal(requests.at(-1).keyword, '')
|
|
assert.equal(restoreTimers.timers.size, 1)
|
|
global.document.visibilityState = 'hidden'
|
|
f.panel.updateVisibility()
|
|
assert.equal(restoreTimers.timers.size, 0)
|
|
const before = requests.length
|
|
await f.panel.refreshResults()
|
|
assert.equal(requests.length, before)
|
|
global.document.visibilityState = 'visible'
|
|
f.panel.updateVisibility()
|
|
await settle()
|
|
assert.equal(requests.length, before + 1)
|
|
f.panel.drawerOpened.value = false
|
|
f.panel.drawerClosed()
|
|
assert.equal(f.panel.selectedStaff.value, null)
|
|
assert.equal(f.panel.page.value, 3)
|
|
assert.equal(f.panel.filter.value, 'attention')
|
|
} finally {
|
|
f.dispose()
|
|
assert.equal(restoreTimers.timers.size, 0)
|
|
restoreTimers()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('permission revalidation clears team and drawer before its response and denial hides old results', async () => {
|
|
const restore = installDocument(),
|
|
denied = deferred()
|
|
let deny = false
|
|
const f = setup(
|
|
'workbench/index.vue',
|
|
{},
|
|
workspaceApi({
|
|
assistantCapabilities: async () => (deny ? denied.promise : capabilities)
|
|
})
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
await f.panel.setSurface('daily')
|
|
await settle()
|
|
f.panel.openStaff(f.panel.team.value.lists[0])
|
|
deny = true
|
|
const recheck = f.panel.initialize()
|
|
assert.equal(f.panel.team.value, null)
|
|
assert.equal(f.panel.selectedStaff.value, null)
|
|
denied.resolve({ ...capabilities, permissions: [] })
|
|
await recheck
|
|
assert.equal(f.panel.can('view'), false)
|
|
assert.equal(f.panel.team.value, null)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('staff drawer resets target immediately and discards all previous staff responses', async () => {
|
|
const waits = {},
|
|
signals = []
|
|
const read = (kind, params, signal) => {
|
|
signals.push(signal)
|
|
if (params.assistant_id === 8) {
|
|
waits[kind] = deferred()
|
|
return waits[kind].promise
|
|
}
|
|
if (kind === 'overview') return Promise.resolve(overview(9))
|
|
return Promise.resolve(
|
|
page(kind === 'cases' ? [staffCase(97, 9)] : [{ diagnosis_id: 97, reason: '当前问题' }])
|
|
)
|
|
}
|
|
const f = setup(
|
|
'components/StaffDetail.vue',
|
|
{ staff: staff(8), capabilities, refreshKey: 0 },
|
|
workspaceApi({
|
|
assistantStaffOverview: (params, signal) => read('overview', params, signal),
|
|
assistantStaffCases: (params, signal) => read('cases', params, signal),
|
|
assistantStaffIssues: (params, signal) => read('issues', params, signal)
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
f.panel.detail.value = { patient: context(1).patient }
|
|
f.props.staff = staff(9)
|
|
assert.equal(f.panel.overview.value, null)
|
|
assert.equal(f.panel.detail.value, null)
|
|
assert.equal(signals[0].aborted, true)
|
|
await settle()
|
|
for (const [kind, value] of Object.entries(waits))
|
|
value.resolve(kind === 'overview' ? overview(8) : page([staffCase(8)]))
|
|
await settle()
|
|
assert.equal(f.panel.overview.value.staff.id, 9)
|
|
assert.equal(f.panel.overview.value.days, 30)
|
|
assert.equal(f.panel.cases.value[0].diagnosis_id, 97)
|
|
assert.equal(f.panel.issues.value[0].reason, '当前问题')
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('staff drawer refresh preserves record navigation, search draft and both pagination positions', async () => {
|
|
const requests = []
|
|
const f = setup(
|
|
'components/StaffDetail.vue',
|
|
{ staff: staff(9), capabilities, refreshKey: 0 },
|
|
workspaceApi({
|
|
assistantStaffCases: async (params) => {
|
|
requests.push(params)
|
|
return page([staffCase(9)])
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
f.panel.activeTab.value = 'cases'
|
|
f.panel.casesPage.value = 3
|
|
f.panel.issuesPage.value = 2
|
|
f.panel.caseKeyword.value = '正在编辑'
|
|
f.panel.openIssue({ job_id: 5, diagnosis_id: 9, patient_name: '核查患者' })
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(f.panel.detail.value.jobId, 5)
|
|
assert.equal(f.panel.activeTab.value, 'cases')
|
|
assert.equal(f.panel.casesPage.value, 3)
|
|
assert.equal(f.panel.issuesPage.value, 2)
|
|
assert.equal(f.panel.caseKeyword.value, '正在编辑')
|
|
assert.equal(requests.at(-1).keyword, '')
|
|
assert.equal(requests.at(-1).assistant_id, 9)
|
|
assert.equal(requests.at(-1).days, 30)
|
|
f.panel.closeDetail()
|
|
await settle()
|
|
assert.equal(f.panel.detail.value, null)
|
|
assert.equal(f.panel.casesPage.value, 3)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('staff drawer refuses overview belonging to a different target', async () => {
|
|
const f = setup(
|
|
'components/StaffDetail.vue',
|
|
{ staff: staff(9), capabilities, refreshKey: 0 },
|
|
workspaceApi({
|
|
assistantStaffOverview: async () => overview(8)
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
assert.equal(f.panel.overview.value, null)
|
|
assert.match(f.panel.overviewError.value, /不一致/)
|
|
f.panel.openCase(staffCase(8))
|
|
assert.equal(f.panel.detail.value, null)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('actual staff tasks load across patients by assignee, clear on staff switch, and reject stale lists', async () => {
|
|
const old = deferred(),
|
|
requests = []
|
|
const f = setup(
|
|
'components/ServiceActions.vue',
|
|
{ assistantId: 8, actorId: 8, canComplete: true, canReview: true, refreshKey: 0 },
|
|
{
|
|
assistantActions: async (params) => {
|
|
requests.push(params)
|
|
return params.assistant_id === 8
|
|
? old.promise
|
|
: page([
|
|
{ id: 2, diagnosis_id: 202, patient_name: '合成患者二', assignee_id: 9 }
|
|
])
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
await settle()
|
|
assert.equal(requests[0].assistant_id, 8)
|
|
assert.equal('diagnosis_id' in requests[0], false)
|
|
f.panel.rows.value = [{ id: 1, diagnosis_id: 101 }]
|
|
f.panel.opened.value = true
|
|
f.props.assistantId = 9
|
|
assert.equal(f.panel.rows.value.length, 0)
|
|
assert.equal(f.panel.opened.value, false)
|
|
await settle()
|
|
old.resolve(page([{ id: 1, diagnosis_id: 101 }]))
|
|
await settle()
|
|
assert.equal(f.panel.rows.value[0].diagnosis_id, 202)
|
|
assert.equal(f.panel.rows.value[0].patient_name, '合成患者二')
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('silent action refresh keeps the current page and an unfinished execution note', async () => {
|
|
const requests = []
|
|
const item = {
|
|
id: 1,
|
|
diagnosis_id: 3,
|
|
assignee_id: 8,
|
|
business_state: 'pending',
|
|
content: '待核查'
|
|
}
|
|
const f = setup(
|
|
'components/ServiceActions.vue',
|
|
{ assistantId: 8, actorId: 8, canComplete: true, canReview: true, refreshKey: 0 },
|
|
{
|
|
assistantActions: async (params) => {
|
|
requests.push(params)
|
|
return page([item])
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
await settle()
|
|
f.panel.page.value = 3
|
|
f.panel.edit(item)
|
|
f.panel.note.value = '正在补充执行情况'
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(f.panel.opened.value, true)
|
|
assert.equal(f.panel.note.value, '正在补充执行情况')
|
|
assert.equal(f.panel.page.value, 3)
|
|
assert.equal(requests.at(-1).page_no, 3)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('automatic case mode reads results, keeps active review and inputs, and never submits analysis', async () => {
|
|
const restore = installDocument(),
|
|
writes = [],
|
|
readCalls = []
|
|
let latest = 30
|
|
const f = setup(
|
|
'components/CaseDetail.vue',
|
|
{
|
|
patient: context(3).patient,
|
|
assistantId: 9,
|
|
days: 30,
|
|
capabilities,
|
|
automatic: true,
|
|
initialJobId: 30,
|
|
refreshKey: 0
|
|
},
|
|
{
|
|
assistantContext: async (params) => {
|
|
readCalls.push(params)
|
|
return {
|
|
...context(3, 9),
|
|
latest_job: { ...job(latest, 3, 'ready'), subject_admin_id: 9 },
|
|
latest_analysis_job: { ...job(latest, 3, 'ready'), subject_admin_id: 9 }
|
|
}
|
|
},
|
|
assistantJobs: async () => page([]),
|
|
assistantJob: async (id) => ({ ...job(id, 3, 'ready'), subject_admin_id: 9 }),
|
|
assistantAnalyze: async (data) => {
|
|
writes.push(data)
|
|
return job(30, 3)
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 30)
|
|
await f.panel.generate('analysis')
|
|
assert.equal(writes.length, 0)
|
|
f.panel.question.value = '正在输入的回复问题'
|
|
f.panel.activeTab.value = 'reply'
|
|
f.panel.historyPage.value = 2
|
|
latest = 31
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 30)
|
|
assert.equal(f.panel.newAnalysisAvailable.value, true)
|
|
assert.equal(f.panel.question.value, '正在输入的回复问题')
|
|
assert.equal(f.panel.activeTab.value, 'reply')
|
|
assert.equal(f.panel.historyPage.value, 2)
|
|
assert.equal(readCalls.at(-1).assistant_id, 9)
|
|
assert.equal(readCalls.at(-1).days, 30)
|
|
f.panel.showLatestAnalysis()
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 31)
|
|
assert.equal(f.panel.activeTab.value, 'analysis')
|
|
assert.equal(f.panel.newAnalysisAvailable.value, false)
|
|
global.document.visibilityState = 'hidden'
|
|
const before = readCalls.length
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(readCalls.length, before)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('a newer reply cannot hide an analysis outside the current history page or replace active review', async () => {
|
|
const restore = installDocument(),
|
|
historyReads = [],
|
|
writes = []
|
|
let latestAnalysis = job(30, 3, 'ready'),
|
|
latestReply = null
|
|
const f = setup(
|
|
'components/CaseDetail.vue',
|
|
{
|
|
patient: context(3).patient,
|
|
assistantId: 8,
|
|
days: 30,
|
|
capabilities,
|
|
automatic: true,
|
|
initialJobId: 30,
|
|
refreshKey: 0
|
|
},
|
|
{
|
|
assistantContext: async () => ({
|
|
...context(3),
|
|
latest_job: latestReply || latestAnalysis,
|
|
latest_analysis_job: latestAnalysis
|
|
}),
|
|
assistantJobs: async (params) => {
|
|
historyReads.push(params)
|
|
// 当前页不包含最新分析,第一页也可能只有回复。
|
|
return page(
|
|
params.page_no === 2 ? [job(29, 3, 'ready')] : latestReply ? [latestReply] : []
|
|
)
|
|
},
|
|
assistantJob: async (id) => job(id, 3, 'ready', id === 32 ? 'reply' : 'analysis'),
|
|
assistantDraftReply: async (params) => {
|
|
writes.push(params)
|
|
latestReply = job(32, 3, 'ready', 'reply')
|
|
return latestReply
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
latestAnalysis = job(31, 3, 'ready')
|
|
f.panel.question.value = '准备人工核对的回复问题'
|
|
await f.panel.generate('reply')
|
|
assert.equal(writes.length, 1)
|
|
f.panel.question.value = '生成后仍在编辑的问题'
|
|
f.panel.activeTab.value = 'reply'
|
|
f.panel.historyPage.value = 2
|
|
f.panel.evidenceOpened.value = true
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 30)
|
|
assert.equal(f.panel.replyState.job.id, 32)
|
|
assert.equal(f.panel.newAnalysisAvailable.value, true)
|
|
assert.equal(f.panel.question.value, '生成后仍在编辑的问题')
|
|
assert.equal(f.panel.activeTab.value, 'reply')
|
|
assert.equal(f.panel.evidenceOpened.value, true)
|
|
assert.equal(f.panel.historyPage.value, 2)
|
|
assert.equal(historyReads.at(-1).page_no, 2)
|
|
assert.deepEqual(
|
|
f.panel.history.value.map((item) => item.id),
|
|
[29]
|
|
)
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 30)
|
|
assert.equal(f.panel.newAnalysisAvailable.value, true)
|
|
f.panel.showLatestAnalysis()
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 31)
|
|
assert.equal(f.panel.replyState.job.id, 32)
|
|
assert.equal(f.panel.newAnalysisAvailable.value, false)
|
|
assert.equal(f.panel.question.value, '生成后仍在编辑的问题')
|
|
assert.equal(f.panel.activeTab.value, 'analysis')
|
|
assert.deepEqual(
|
|
f.panel.history.value.map((item) => item.id),
|
|
[32]
|
|
)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('initial case analysis is independent of reply-only history and older history rows', async () => {
|
|
const restore = installDocument(),
|
|
latestAnalysis = job(31, 3, 'ready'),
|
|
replies = Array.from({ length: 10 }, (_, index) => job(50 - index, 3, 'ready', 'reply'))
|
|
let rows = replies
|
|
const f = setup(
|
|
'components/CaseDetail.vue',
|
|
{ patient: context(3).patient, assistantId: 8, days: 30, capabilities, automatic: true },
|
|
{
|
|
assistantContext: async () => ({
|
|
...context(3),
|
|
latest_job: replies[0],
|
|
latest_analysis_job: latestAnalysis
|
|
}),
|
|
assistantJobs: async () => ({ ...page(rows), count: 22 }),
|
|
assistantJob: async (id) => job(id, 3, 'ready', id >= 41 ? 'reply' : 'analysis')
|
|
}
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 31)
|
|
assert.equal(f.panel.replyState.job.id, 50)
|
|
assert.equal(f.panel.newAnalysisAvailable.value, false)
|
|
assert.ok(f.panel.history.value.every((item) => item.type === 'reply'))
|
|
rows = [job(29, 3, 'ready')]
|
|
await f.panel.reloadContext(true)
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 31)
|
|
f.panel.openHistory(rows[0])
|
|
await settle()
|
|
assert.equal(f.panel.analysisState.job.id, 29)
|
|
assert.equal(f.panel.newAnalysisAvailable.value, true)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('case context rejects a latest analysis for another assistant, diagnosis or task type', async () => {
|
|
const restore = installDocument()
|
|
try {
|
|
for (const invalid of [
|
|
{ ...job(31, 3, 'ready'), subject_admin_id: 9 },
|
|
job(31, 4, 'ready'),
|
|
job(31, 3, 'ready', 'reply')
|
|
]) {
|
|
let latest = job(30, 3, 'ready')
|
|
const f = setup(
|
|
'components/CaseDetail.vue',
|
|
{
|
|
patient: context(3).patient,
|
|
assistantId: 8,
|
|
days: 30,
|
|
capabilities,
|
|
automatic: true,
|
|
refreshKey: 0
|
|
},
|
|
{
|
|
assistantContext: async () => ({ ...context(3), latest_analysis_job: latest }),
|
|
assistantJobs: async () => page([]),
|
|
assistantJob: async (id) => job(id, 3, 'ready')
|
|
}
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
latest = invalid
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.match(f.panel.contextError.value, /医助或关联诊单不一致/)
|
|
assert.equal(f.panel.context.value, null)
|
|
assert.equal(f.panel.analysisState.job, null)
|
|
await f.panel.reloadContext()
|
|
assert.match(f.panel.contextError.value, /医助或关联诊单不一致/)
|
|
assert.equal(f.panel.context.value, null)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
}
|
|
} finally {
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('actual case detail sends target and time to context, history, analysis and reply and defaults to service review', async () => {
|
|
const restore = installDocument(),
|
|
calls = [],
|
|
waits = []
|
|
const f = setup(
|
|
'components/CaseDetail.vue',
|
|
{ patient: context(3).patient, assistantId: 9, days: 7, capabilities },
|
|
{
|
|
assistantContext: async (params) => {
|
|
calls.push(['context', params])
|
|
return context(3, params.assistant_id)
|
|
},
|
|
assistantJobs: async (params) => {
|
|
calls.push(['jobs', params])
|
|
return page([])
|
|
},
|
|
assistantAnalyze: async (params) => {
|
|
calls.push(['analysis', params])
|
|
return { ...job(30, 3, 'ready'), subject_admin_id: 9 }
|
|
},
|
|
assistantDraftReply: async (params) => {
|
|
calls.push(['reply', params])
|
|
return { ...job(31, 3, 'ready', 'reply'), subject_admin_id: 9 }
|
|
},
|
|
assistantJob: async (id) => ({ ...job(id, 3, 'ready'), subject_admin_id: 9 })
|
|
}
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
assert.equal(f.panel.activeTab.value, 'analysis')
|
|
await f.panel.generate('analysis')
|
|
f.panel.question.value = '核对预约'
|
|
await f.panel.generate('reply')
|
|
for (const [name, params] of calls) {
|
|
assert.equal(params.assistant_id, 9, name)
|
|
assert.equal(params.diagnosis_id, 3, name)
|
|
if (name !== 'jobs') assert.equal(params.days, 7, name)
|
|
}
|
|
assert.ok(calls.some(([name]) => name === 'reply'))
|
|
assert.equal(f.panel.analysisState.job.subject_admin_id, 9)
|
|
assert.equal(f.panel.replyState.job.subject_admin_id, 9)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('actual case detail rejects context belonging to another assistant and target changes cancel old sources', async () => {
|
|
const restore = installDocument(),
|
|
old = deferred()
|
|
const f = setup(
|
|
'components/CaseDetail.vue',
|
|
{ patient: context(3).patient, assistantId: 8, days: 30, capabilities },
|
|
{
|
|
assistantContext: async (params) =>
|
|
params.assistant_id === 8 ? old.promise : context(3, 8),
|
|
assistantJobs: async () => page([])
|
|
}
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
f.props.assistantId = 9
|
|
await settle()
|
|
assert.equal(f.panel.context.value, null)
|
|
assert.match(f.panel.contextError.value, /医助/)
|
|
old.resolve(context(3, 8))
|
|
await settle()
|
|
assert.equal(f.panel.context.value, null)
|
|
assert.equal(f.panel.canGenerate.value, false)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('actual result defaults to service perspective and supervisor adoption requires review authorization for another assistant', async () => {
|
|
const item = { key: 'follow', text: '核对跟进', evidence_ids: [] },
|
|
calls = []
|
|
const f = setup(
|
|
'components/JobResult.vue',
|
|
{
|
|
job: {
|
|
...job(1, 3, 'ready'),
|
|
subject_admin_id: 9,
|
|
result: { ...analysis(), suggested_actions: [item] }
|
|
},
|
|
permissions: ['adoptAction'],
|
|
checking: false,
|
|
error: '',
|
|
disabled: false
|
|
},
|
|
{
|
|
assistantAdoptAction: async (data) => {
|
|
calls.push(data)
|
|
return { id: 1, todo_id: 2 }
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
assert.equal(f.panel.perspective.value, 'service')
|
|
assert.equal(f.panel.canAdopt.value, false)
|
|
f.panel.openAdopt(item)
|
|
f.panel.dueAt.value = new Date(Date.now() + 60000)
|
|
await f.panel.adopt()
|
|
assert.equal(calls.length, 0)
|
|
f.props.permissions = ['adoptAction', 'reviewQuality']
|
|
await f.panel.adopt()
|
|
assert.equal(calls.length, 1)
|
|
assert.equal(calls[0].job_id, 1)
|
|
assert.ok(f.calls.some((call) => call[1] === '已分配给该医助'))
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('daily contacts API is a cancellable read with only the frozen target, status and page parameters', async () => {
|
|
const calls = [],
|
|
signal = new AbortController().signal
|
|
const api = evaluate(
|
|
fs.readFileSync(path.join(__dirname, '../src/api/assistantService.ts'), 'utf8'),
|
|
() => ({
|
|
default: {
|
|
get: async (config, options) => {
|
|
calls.push({ config, options })
|
|
return { code: 1, data: dailyContacts() }
|
|
}
|
|
}
|
|
})
|
|
)
|
|
const result = await api.assistantStaffDailyContacts(
|
|
{ assistant_id: 9, status: 'reported', page_no: 2, page_size: 10 },
|
|
signal
|
|
)
|
|
assert.equal(calls[0].config.url, '/tcm.assistantService/staffDailyContacts')
|
|
assert.equal(calls[0].config.signal, signal)
|
|
assert.equal(calls[0].options.isOpenRetry, false)
|
|
assert.deepEqual(calls[0].config.params, {
|
|
assistant_id: 9,
|
|
status: 'reported',
|
|
page_no: 2,
|
|
page_size: 10
|
|
})
|
|
assert.equal(result.daily.timezone, 'Asia/Shanghai')
|
|
})
|
|
|
|
test('today entry selects pending or reported directly and service review keeps its own entry without model readiness', async () => {
|
|
const restore = installDocument(),
|
|
writes = []
|
|
const f = setup(
|
|
'workbench/index.vue',
|
|
{},
|
|
workspaceApi({
|
|
assistantCapabilities: async () => ({
|
|
...capabilities,
|
|
ready: false,
|
|
reason: '模型未配置'
|
|
}),
|
|
assistantAnalyze: async (...args) => writes.push(args),
|
|
assistantAnalyzeStaff: async (...args) => writes.push(args)
|
|
})
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
await f.panel.setSurface('daily')
|
|
await settle()
|
|
const row = f.panel.team.value.lists[0]
|
|
assert.equal(f.panel.businessDate.value, '2026-11-10')
|
|
assert.equal(f.panel.team.value.daily_summary.pending, 4)
|
|
f.panel.openStaff(row)
|
|
assert.equal(f.panel.initialView.value, 'daily')
|
|
assert.equal(f.panel.initialStatus.value, 'pending')
|
|
const entry = f.panel.entryKey.value
|
|
f.panel.openStaff(row, 'daily', 'reported')
|
|
assert.equal(f.panel.initialStatus.value, 'reported')
|
|
assert.equal(f.panel.entryKey.value, entry + 1)
|
|
f.panel.openStaff(row, 'issues')
|
|
assert.equal(f.panel.initialView.value, 'issues')
|
|
assert.equal(f.panel.selectedStaff.value.id, row.staff.id)
|
|
assert.equal(writes.length, 0)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('daily list preserves whole-patient summary across status and page reads and opens only authorized diagnosis references', async () => {
|
|
const requests = [],
|
|
writes = []
|
|
const f = setup(
|
|
'components/DailyContacts.vue',
|
|
{ assistantId: 9, businessDate: '2026-11-10', refreshKey: 0, allowed: true },
|
|
workspaceApi({
|
|
assistantStaffDailyContacts: async (params) => {
|
|
requests.push(params)
|
|
return dailyContacts(9, {
|
|
...page([dailyPatient(3, params.status === 'all' ? 'pending' : params.status)]),
|
|
count: 14
|
|
})
|
|
},
|
|
assistantAnalyze: async (...args) => writes.push(args),
|
|
assistantCompleteAction: async (...args) => writes.push(args)
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
assert.equal(f.panel.status.value, 'pending')
|
|
assert.equal(f.panel.data.value.summary.required, 4)
|
|
assert.equal(f.panel.data.value.lists.length, 1)
|
|
assert.equal(f.panel.data.value.count, 14)
|
|
f.panel.setStatus('reported')
|
|
await settle()
|
|
f.panel.page.value = 2
|
|
await f.panel.load()
|
|
assert.equal(requests.at(-1).status, 'reported')
|
|
assert.equal(requests.at(-1).page_no, 2)
|
|
assert.equal(f.panel.data.value.summary.required, 4)
|
|
assert.equal(f.panel.data.value.summary.contacted, 1)
|
|
const patient = f.panel.data.value.lists[0]
|
|
f.panel.openPatient(patient, 999)
|
|
assert.equal(f.events.length, 0)
|
|
f.panel.openPatient(patient, 103)
|
|
assert.equal(f.events[0][0], 'open')
|
|
assert.equal(f.events[0][1].diagnosis_id, 103)
|
|
assert.equal(f.events[0][1].assistant_id, 9)
|
|
assert.equal(writes.length, 0)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('daily list cancels old staff reads, rejects mismatched staff and clears data immediately on permission loss', async () => {
|
|
const old = deferred(),
|
|
signals = []
|
|
let invalid = false
|
|
const f = setup(
|
|
'components/DailyContacts.vue',
|
|
{ assistantId: 8, businessDate: '2026-11-10', refreshKey: 0, allowed: true },
|
|
workspaceApi({
|
|
assistantStaffDailyContacts: async (params, signal) => {
|
|
signals.push(signal)
|
|
return params.assistant_id === 8 ? old.promise : dailyContacts(invalid ? 8 : 9)
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
f.props.assistantId = 9
|
|
assert.equal(signals[0].aborted, true)
|
|
assert.equal(f.panel.data.value, null)
|
|
await settle()
|
|
old.resolve(dailyContacts(8))
|
|
await settle()
|
|
assert.equal(f.panel.data.value.staff.id, 9)
|
|
invalid = true
|
|
await f.panel.load(true)
|
|
assert.equal(f.panel.data.value, null)
|
|
assert.match(f.panel.error.value, /医助或业务日期不一致/)
|
|
assert.ok(f.events.some(([name]) => name === 'unavailable'))
|
|
invalid = false
|
|
await f.panel.load()
|
|
const before = signals.length
|
|
f.props.allowed = false
|
|
assert.equal(f.panel.data.value, null)
|
|
assert.equal(signals.at(-1).aborted, true)
|
|
await f.panel.load()
|
|
assert.equal(signals.length, before)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('daily list distinguishes no tasks, complete, unavailable, partial and read failure without deriving completion from an empty page', async () => {
|
|
let result = dailyContacts(9, {
|
|
...page([]),
|
|
summary: dailyCounts({
|
|
required: 0,
|
|
contacted: 0,
|
|
pending: 0,
|
|
reported: 0,
|
|
outstanding: 0,
|
|
state: 'no_tasks'
|
|
})
|
|
})
|
|
const f = setup(
|
|
'components/DailyContacts.vue',
|
|
{ assistantId: 9, businessDate: '2026-11-10', refreshKey: 0, allowed: true },
|
|
workspaceApi({
|
|
assistantStaffDailyContacts: async () => {
|
|
if (result instanceof Error) throw result
|
|
return result
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
assert.equal(f.panel.emptyDescription.value, '今日暂无应维护患者')
|
|
result = dailyContacts(9, {
|
|
...page([]),
|
|
summary: dailyCounts({
|
|
required: 4,
|
|
contacted: 4,
|
|
pending: 0,
|
|
reported: 0,
|
|
outstanding: 0,
|
|
state: 'done'
|
|
})
|
|
})
|
|
await f.panel.load()
|
|
assert.equal(f.panel.data.value.summary.state, 'done')
|
|
assert.equal(f.panel.emptyDescription.value, '当前筛选暂无患者')
|
|
for (const state of ['waiting', 'partial', 'unavailable']) {
|
|
result = dailyContacts(9, {
|
|
...page([]),
|
|
daily: dailyMeta({ state, reason: '来源待确认' }),
|
|
summary: dailyCounts({
|
|
required: null,
|
|
contacted: null,
|
|
pending: null,
|
|
reported: null,
|
|
outstanding: null,
|
|
exempt: null,
|
|
state: 'unavailable'
|
|
})
|
|
})
|
|
await f.panel.load()
|
|
assert.equal(f.panel.data.value.summary.required, null)
|
|
assert.match(f.panel.emptyDescription.value, /不可确认/)
|
|
}
|
|
result = new Error('权限已变更')
|
|
await f.panel.load(true)
|
|
assert.equal(f.panel.data.value, null)
|
|
assert.equal(f.panel.error.value, '权限已变更')
|
|
assert.match(f.panel.emptyDescription.value, /读取失败/)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('same-day daily refresh keeps status and pagination while updating evidence and summary', async () => {
|
|
const requests = []
|
|
let recorded = false
|
|
const f = setup(
|
|
'components/DailyContacts.vue',
|
|
{
|
|
assistantId: 9,
|
|
businessDate: '2026-11-10',
|
|
refreshKey: 0,
|
|
initialStatus: 'reported',
|
|
entryKey: 1,
|
|
allowed: true
|
|
},
|
|
workspaceApi({
|
|
assistantStaffDailyContacts: async (params) => {
|
|
requests.push(params)
|
|
return dailyContacts(9, {
|
|
...page(recorded ? [] : [dailyPatient(3, 'reported')]),
|
|
summary: dailyCounts({
|
|
contacted: recorded ? 2 : 1,
|
|
reported: recorded ? 0 : 1
|
|
})
|
|
})
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
f.panel.page.value = 3
|
|
recorded = true
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(f.panel.status.value, 'reported')
|
|
assert.equal(f.panel.page.value, 3)
|
|
assert.equal(requests.at(-1).page_no, 3)
|
|
assert.equal(f.panel.data.value.summary.contacted, 2)
|
|
assert.equal(f.panel.data.value.summary.reported, 0)
|
|
f.props.initialStatus = 'pending'
|
|
f.props.entryKey++
|
|
await settle()
|
|
assert.equal(f.panel.status.value, 'pending')
|
|
assert.equal(f.panel.page.value, 1)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('server business-date rollover resets daily page and rejects late old-day responses and wrong timezone', async () => {
|
|
const old = deferred(),
|
|
requests = []
|
|
let count = 0,
|
|
returnedDate = '2026-11-11',
|
|
timezone = 'Asia/Shanghai'
|
|
const f = setup(
|
|
'components/DailyContacts.vue',
|
|
{
|
|
assistantId: 9,
|
|
businessDate: '2026-11-10',
|
|
refreshKey: 0,
|
|
initialStatus: 'reported',
|
|
allowed: true
|
|
},
|
|
workspaceApi({
|
|
assistantStaffDailyContacts: async (params) => {
|
|
requests.push(params)
|
|
if (++count === 1) return old.promise
|
|
return dailyContacts(9, {
|
|
daily: dailyMeta({ business_date: returnedDate, timezone })
|
|
})
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
f.panel.page.value = 3
|
|
f.props.businessDate = '2026-11-11'
|
|
assert.equal(f.panel.data.value, null)
|
|
assert.equal(f.panel.page.value, 1)
|
|
await settle()
|
|
old.resolve(dailyContacts(9))
|
|
await settle()
|
|
assert.equal(f.panel.data.value.daily.business_date, '2026-11-11')
|
|
assert.equal(f.panel.status.value, 'reported')
|
|
assert.equal(requests.at(-1).page_no, 1)
|
|
returnedDate = '2026-11-10'
|
|
await f.panel.load(true)
|
|
assert.equal(f.panel.data.value, null)
|
|
assert.match(f.panel.error.value, /旧业务日期/)
|
|
returnedDate = '2026-11-12'
|
|
await f.panel.load(true)
|
|
assert.equal(f.panel.data.value, null)
|
|
assert.ok(f.events.some(([name, date]) => name === 'date-change' && date === '2026-11-12'))
|
|
timezone = 'UTC'
|
|
await f.panel.load(true)
|
|
assert.match(f.panel.error.value, /业务日期不一致/)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('team refuses a response older than the latest server business date while retaining the open staff and search draft', async () => {
|
|
const restore = installDocument(),
|
|
old = deferred()
|
|
let count = 0,
|
|
date = '2026-11-10'
|
|
const f = setup(
|
|
'workbench/index.vue',
|
|
{},
|
|
workspaceApi({
|
|
assistantTeamOverview: async () => {
|
|
if (++count === 2) return old.promise
|
|
return { ...teamOverview(), daily: dailyMeta({ business_date: date }) }
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
await f.panel.setSurface('daily')
|
|
await settle()
|
|
f.panel.openStaff(f.panel.team.value.lists[0])
|
|
f.panel.keyword.value = '编辑中的搜索'
|
|
const read = f.panel.loadTeam(true)
|
|
date = '2026-11-11'
|
|
f.panel.observeBusinessDate(date)
|
|
await settle()
|
|
old.resolve(teamOverview())
|
|
await read
|
|
await settle()
|
|
assert.equal(f.panel.team.value.daily.business_date, date)
|
|
assert.equal(f.panel.businessDate.value, date)
|
|
assert.equal(f.panel.drawerOpened.value, true)
|
|
assert.equal(f.panel.keyword.value, '编辑中的搜索')
|
|
date = '2026-11-10'
|
|
await f.panel.loadTeam(true)
|
|
assert.equal(f.panel.team.value.daily.business_date, '2026-11-11')
|
|
assert.equal(f.panel.businessDate.value, '2026-11-11')
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
test('daily record navigation survives quality-only errors and refreshes, with independent denial and same-staff view changes', async () => {
|
|
const f = setup(
|
|
'components/StaffDetail.vue',
|
|
{
|
|
staff: staff(9),
|
|
capabilities: { ...capabilities },
|
|
refreshKey: 0,
|
|
businessDate: '2026-11-10',
|
|
initialView: 'daily',
|
|
initialStatus: 'pending',
|
|
entryKey: 1
|
|
},
|
|
workspaceApi({
|
|
assistantStaffOverview: async () => {
|
|
throw new Error('近期质检暂不可用')
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
assert.equal(f.panel.activeTab.value, 'daily')
|
|
assert.match(f.panel.overviewError.value, /暂不可用/)
|
|
f.panel.openDaily({ ...context(3).patient, assistant_id: 9 })
|
|
assert.equal(f.panel.detail.value.patient.diagnosis_id, 3)
|
|
const refresh = f.panel.dailyRefresh.value
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(f.panel.detail.value.daily, true)
|
|
assert.ok(f.panel.dailyRefresh.value > refresh)
|
|
f.panel.closeDetail()
|
|
await settle()
|
|
assert.equal(f.panel.activeTab.value, 'daily')
|
|
f.panel.openDaily({ ...context(3).patient, assistant_id: 9 })
|
|
f.panel.dailyUnavailable()
|
|
assert.equal(f.panel.detail.value, null)
|
|
f.props.initialView = 'issues'
|
|
f.props.entryKey++
|
|
await settle()
|
|
assert.equal(f.panel.activeTab.value, 'issues')
|
|
f.props.capabilities = { ...capabilities, permissions: [] }
|
|
assert.equal(f.panel.detail.value, null)
|
|
assert.equal(f.panel.overview.value, null)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('adopting a suggested action requires explicit contact opt-in and each new adoption starts unchecked', async () => {
|
|
const calls = [],
|
|
item = { key: 'follow', text: '核对随访', evidence_ids: [] }
|
|
const f = setup(
|
|
'components/JobResult.vue',
|
|
{
|
|
job: { ...job(1, 3, 'ready'), result: { ...analysis(), suggested_actions: [item] } },
|
|
permissions: ['adoptAction'],
|
|
checking: false,
|
|
error: '',
|
|
disabled: false
|
|
},
|
|
{
|
|
assistantAdoptAction: async (data) => {
|
|
calls.push(data)
|
|
return { id: 1, todo_id: 2 }
|
|
}
|
|
}
|
|
)
|
|
try {
|
|
f.panel.openAdopt(item)
|
|
assert.equal(f.panel.contactRequired.value, false)
|
|
f.panel.dueAt.value = new Date(Date.now() + 60000)
|
|
await f.panel.adopt()
|
|
assert.equal(calls[0].contact_required, false)
|
|
f.panel.openAdopt(item)
|
|
f.panel.contactRequired.value = true
|
|
f.panel.dueAt.value = new Date(Date.now() + 60000)
|
|
await f.panel.adopt()
|
|
assert.equal(calls[1].contact_required, true)
|
|
f.panel.openAdopt(item)
|
|
assert.equal(f.panel.contactRequired.value, false)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('daily list pauses hidden and inactive reads and discards a response already in flight', async () => {
|
|
const restore = installDocument(),
|
|
old = deferred(),
|
|
requests = []
|
|
let count = 0
|
|
const f = setup(
|
|
'components/DailyContacts.vue',
|
|
{ assistantId: 9, businessDate: '2026-11-10', refreshKey: 0, allowed: true },
|
|
workspaceApi({
|
|
assistantStaffDailyContacts: async (params, signal) => {
|
|
requests.push({ params, signal })
|
|
return ++count === 1 ? old.promise : dailyContacts(9)
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
global.document.visibilityState = 'hidden'
|
|
f.panel.updateVisibility()
|
|
assert.equal(requests[0].signal.aborted, true)
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(requests.length, 1)
|
|
old.resolve(dailyContacts(9))
|
|
await settle()
|
|
assert.equal(f.panel.data.value, null)
|
|
global.document.visibilityState = 'visible'
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(f.panel.data.value.staff.id, 9)
|
|
f.hooks.onDeactivated()
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(requests.length, 2)
|
|
f.hooks.onActivated()
|
|
f.props.refreshKey++
|
|
await settle()
|
|
assert.equal(requests.length, 3)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|
|
|
|
function alertRow(id = 4, overrides = {}) {
|
|
return {
|
|
id,
|
|
staff: staff(9),
|
|
patient_id: 3,
|
|
patient_name: '合成患者3',
|
|
diagnosis_id: 3,
|
|
channel: 'im',
|
|
rule_code: 'clinical_boundary',
|
|
rule_label: '越界诊疗',
|
|
rule_version: 1,
|
|
severity: 'high',
|
|
snippet: '诊断为糖尿病',
|
|
status: 'open',
|
|
occurred_at: 1794268800,
|
|
review_note: '',
|
|
reviewed_at: 0,
|
|
...overrides
|
|
}
|
|
}
|
|
function alertPage(lists = [alertRow()], emptyReason = '') {
|
|
return {
|
|
...page(lists),
|
|
page_size: 20,
|
|
schema_ready: true,
|
|
summary: { open: lists.length ? 2 : 0, high_open: lists.length ? 1 : 0, im_open: lists.length ? 2 : 0, call_open: 0 },
|
|
scan: { enabled: true, state: 'ready', last_scan_at: 1794268800, reason: '' },
|
|
coverage: [
|
|
{ channel: 'im', status: 'available', note: '员工侧已归档文字' },
|
|
{
|
|
channel: 'call',
|
|
status: emptyReason ? 'no_transcript' : 'not_connected',
|
|
note: emptyReason || '通话转写尚未接入'
|
|
}
|
|
],
|
|
empty_reason: emptyReason
|
|
}
|
|
}
|
|
|
|
test('alert API keeps filters explicit and does not retry a review', async () => {
|
|
const calls = [],
|
|
signal = new AbortController().signal
|
|
const api = evaluate(
|
|
fs.readFileSync(path.join(__dirname, '../src/api/assistantService.ts'), 'utf8'),
|
|
() => ({
|
|
default: {
|
|
get: async (config, options) => {
|
|
calls.push({ config, options })
|
|
return { code: 1, data: alertPage() }
|
|
},
|
|
post: async (config, options) => {
|
|
calls.push({ config, options })
|
|
return { code: 1, data: { id: 4, status: 'confirmed' } }
|
|
}
|
|
}
|
|
})
|
|
)
|
|
const result = await api.assistantAlerts(
|
|
{ keyword: '甲', status: 'open', severity: 'high', page_no: 2, page_size: 20 },
|
|
signal
|
|
)
|
|
await api.assistantReviewAlert({ id: 4, status: 'false_positive', note: '患者原话' })
|
|
assert.equal(calls[0].config.url, '/tcm.assistantService/alerts')
|
|
assert.equal(calls[0].config.signal, signal)
|
|
assert.equal(calls[0].options.isOpenRetry, false)
|
|
assert.deepEqual(calls[0].config.params, {
|
|
keyword: '甲',
|
|
status: 'open',
|
|
severity: 'high',
|
|
page_no: 2,
|
|
page_size: 20
|
|
})
|
|
assert.equal(result.summary.call_open, 0)
|
|
assert.equal(calls[1].config.url, '/tcm.assistantService/reviewAlert')
|
|
assert.equal(calls[1].options.isOpenRetry, false)
|
|
assert.deepEqual(calls[1].config.data, { id: 4, status: 'false_positive', note: '患者原话' })
|
|
})
|
|
|
|
test('alert inbox reviews only with a reason for false positives and keeps the unfiltered summary', async () => {
|
|
const reads = [],
|
|
reviews = []
|
|
const f = setup(
|
|
'components/AlertInbox.vue',
|
|
{ refreshKey: 0, canReview: true },
|
|
workspaceApi({
|
|
assistantAlerts: async (params) => {
|
|
reads.push(params)
|
|
return alertPage()
|
|
},
|
|
assistantReviewAlert: async (data) => {
|
|
reviews.push(data)
|
|
return { id: data.id, status: data.status }
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
assert.equal(f.panel.filter.value, 'open')
|
|
assert.equal(f.panel.data.value.summary.open, 2)
|
|
assert.equal(f.panel.data.value.summary.call_open, 0)
|
|
assert.equal(reads[0].status, 'open')
|
|
assert.equal(reads[0].severity, 'all')
|
|
const row = f.panel.data.value.lists[0]
|
|
f.panel.openStaff(row)
|
|
assert.equal(f.events[0][0], 'open-staff')
|
|
assert.equal(f.events[0][1].id, 9)
|
|
await f.panel.review(row, 'false_positive')
|
|
assert.equal(reviews.length, 0)
|
|
assert.equal(f.panel.noteTarget.value, row.id)
|
|
f.panel.note.value = '患者原话'
|
|
await f.panel.review(row, 'false_positive')
|
|
assert.deepEqual(reviews[0], { id: row.id, status: 'false_positive', note: '患者原话' })
|
|
await f.panel.review(alertRow(5), 'confirmed')
|
|
assert.equal(reviews[1].status, 'confirmed')
|
|
assert.equal(reviews[1].note, '')
|
|
f.panel.setFilter('high')
|
|
await settle()
|
|
assert.equal(reads.at(-1).status, 'open')
|
|
assert.equal(reads.at(-1).severity, 'high')
|
|
f.panel.setRole('doctor')
|
|
await settle()
|
|
assert.equal(reads.at(-1).role, 'doctor')
|
|
assert.equal(f.panel.role.value, 'doctor')
|
|
assert.equal(f.panel.data.value.summary.open, 2)
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('alert inbox explains an empty list when visible assistants have no archived text', async () => {
|
|
const f = setup(
|
|
'components/AlertInbox.vue',
|
|
{ refreshKey: 0, canReview: false },
|
|
workspaceApi({
|
|
assistantAlerts: async () =>
|
|
alertPage(
|
|
[],
|
|
'可见医助账号没有已归档文字。通话 69 通,有时长 0 通,有转写 0 通。医生账号的聊天和转写不进这个列表。'
|
|
)
|
|
})
|
|
)
|
|
try {
|
|
await settle()
|
|
assert.match(f.panel.emptyDescription.value, /没有已归档文字/)
|
|
assert.equal(f.panel.callMetric.value, '无正文')
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('alert inbox ignores review without permission and discards a stale list response', async () => {
|
|
const old = deferred(),
|
|
signals = []
|
|
let count = 0
|
|
const f = setup(
|
|
'components/AlertInbox.vue',
|
|
{ refreshKey: 0, canReview: false },
|
|
workspaceApi({
|
|
assistantAlerts: async (params, signal) => {
|
|
signals.push(signal)
|
|
if (++count === 1) return old.promise
|
|
return alertPage([alertRow(8)])
|
|
},
|
|
assistantReviewAlert: async () => {
|
|
throw new Error('不应复核')
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
await f.panel.review(alertRow(), 'confirmed')
|
|
f.panel.setFilter('all')
|
|
assert.equal(signals[0].aborted, true)
|
|
await settle()
|
|
old.resolve(alertPage([alertRow(4)]))
|
|
await settle()
|
|
assert.equal(f.panel.data.value.lists[0].id, 8)
|
|
assert.equal(f.panel.filter.value, 'all')
|
|
} finally {
|
|
f.dispose()
|
|
}
|
|
})
|
|
|
|
test('workbench refresh on the alert surface does not load the maintenance roster', async () => {
|
|
const restore = installDocument(),
|
|
reads = []
|
|
const f = setup(
|
|
'workbench/index.vue',
|
|
{},
|
|
workspaceApi({
|
|
assistantTeamOverview: async () => {
|
|
reads.push('team')
|
|
return teamOverview()
|
|
}
|
|
})
|
|
)
|
|
try {
|
|
f.hooks.onMounted()
|
|
await settle()
|
|
f.panel.refresh()
|
|
assert.equal(f.panel.alertRefresh.value, 1)
|
|
assert.equal(reads.length, 0)
|
|
f.panel.openAlertStaff(staff(9))
|
|
assert.equal(f.panel.drawerOpened.value, true)
|
|
assert.equal(f.panel.initialView.value, 'issues')
|
|
assert.equal(f.panel.selectedStaff.value.id, 9)
|
|
} finally {
|
|
f.dispose()
|
|
restore()
|
|
}
|
|
})
|