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