Files
zyt/admin/tests/assistant-service.test.cjs
2026-11-12 09:07:07 +08:00

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()
}
})