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 root = path.resolve(__dirname, '..') const componentDir = path.join(root, 'src/views/tcm/diagnosis/components') function execute(source, dependencies, globals = {}) { const output = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText const module = { exports: {} } new Function('require', 'module', 'exports', ...Object.keys(globals), output)(dependencies, module, module.exports, ...Object.values(globals)) return module.exports } function loadTs(filename, dependencies = require) { return execute(fs.readFileSync(filename, 'utf8'), dependencies) } const state = loadTs(path.join(componentDir, 'followupAudioState.ts')) const playbackModule = loadTs(path.join(componentDir, 'followupAudioPlayback.ts')) const apiModule = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: {} })) 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 < 40; i++) await Promise.resolve() } const caps = () => ({ enabled: true, audio_verified: true, can_upload: true, can_apply: true, can_daily: true, models: [{ value: 'qwen', label: 'Qwen' }, { value: 'openai', label: 'OpenAI' }], limits: { max_bytes: 524288000, max_seconds: 3600, chunk_bytes: 2097152 }, fields: { diagnosis: [{ key: 'remark', label: '备注', type: 'text' }], blood: [{ key: 'fasting_blood_sugar', label: '空腹血糖', type: 'number' }], diet: [], exercise: [], tracking_note: [] } }) const item = (extra = {}) => ({ id: 'blood-1', kind: 'blood', values: { fasting_blood_sugar: 6.5 }, record_date: '2026-09-29', record_time: '08:00:00', time_period: '早晨', time_estimated: true, time_text: '早晨', date_text: '今天', evidence: [{ text: '今天早晨测了6.5', start_ms: 5000, end_ms: 8000 }], needs_review: false, selected: true, target_id: null, current_values: {}, expected_hash: 'immutable-hash', conflict: false, possible_duplicate: false, ...extra }) const task = (id = 1, extra = {}) => ({ id, diagnosis_id: 10, file_name: 'synthetic.wav', recorded_at: '2026-09-29 09:00:00', status: 'review', stage: '', error_code: '', error_message: '', version: 1, created_at: 0, expires_at: 1800000000, can_retry: false, audio_available: true, summary: '测试摘要', transcript: '测试原文', uncertainties: [], items: [item()], applied_items: [], ...extra }) function setupPanel(overrides = {}, propsPatch = {}, options = {}) { const hooks = {}, listeners = new Map(), timers = new Map(), calls = [], events = [], notices = [], urls = [], revoked = [] let nextTimer = 0 const document = { visibilityState: 'visible', addEventListener: (name, callback) => listeners.set(name, callback), removeEventListener: name => listeners.delete(name) } const deps = { ...apiModule, followupAudioLists: async id => { calls.push(['lists', id]); return { items: [task()] } }, followupAudioDetail: async id => { calls.push(['detail', id]); return task(id) }, followupAudioSaveDraft: async payload => { calls.push(['save', payload]); return task(payload.id, { version: payload.version + 1, items: payload.items }) }, followupAudioApply: async payload => { calls.push(['apply', payload]); return { id: payload.id, status: 'applied', applied_items: [] } }, followupAudioBlob: async id => { calls.push(['audio', id]); return new Blob(['synthetic'], { type: 'audio/wav' }) }, followupAudioUploadSession: async payload => { calls.push(['session', payload]); return { upload_id: 'private-id', chunk_bytes: 2097152 } }, followupAudioUploadChunk: async (id, index, blob) => { calls.push(['chunk', id, index, blob.size]); return { index, received: true } }, followupAudioUploadComplete: async id => { calls.push(['complete', id]); return { upload_id: id, duration_seconds: 10, sha256: 'test' } }, followupAudioCreate: async payload => { calls.push(['create', payload]); return { task_id: 2, status: 'queued' } }, followupAudioRetry: async id => { calls.push(['retry', id]); return { id, status: 'queued' } }, ...overrides } const filename = path.join(componentDir, 'FollowupAudioPanel.vue') const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor const script = compileScript(descriptor, { id: 'panel-test' }) const module = execute(script.content, name => { if (name === 'vue') return { ...vue, onMounted: fn => { hooks.mounted = fn }, onBeforeUnmount: fn => { hooks.unmount = fn } } if (name === 'element-plus') return { ElMessage: Object.fromEntries(['success', 'warning', 'error'].map(kind => [kind, msg => notices.push([kind, msg])])), ElMessageBox: { confirm: options.confirm || (async () => true) } } if (name === '@/api/followupAudio') return deps if (name === './followupAudioState') return state if (name === './followupAudioPlayback') return options.playback || playbackModule if (name === './FollowupAudioReview.vue') return { default: {} } throw new Error(`Unexpected import: ${name}`) }, { document, setTimeout: callback => { const id = ++nextTimer; timers.set(id, callback); return id }, clearTimeout: id => timers.delete(id), URL: { createObjectURL: blob => { const url = `blob:private-${urls.length}`; urls.push(url); return url }, revokeObjectURL: url => revoked.push(url) } }) const props = vue.reactive({ diagnosisId: 10, viewOnly: false, active: true, capabilities: caps(), basicDirty: false, ...propsPatch }) const scope = vue.effectScope() const panel = scope.run(() => module.default.setup(props, { expose() {}, emit: (...args) => events.push(args) })) hooks.mounted() return { panel, props, calls, events, notices, timers, listeners, document, urls, revoked, dispose() { hooks.unmount(); scope.stop() }, tick() { const entries = [...timers.values()]; timers.clear(); for (const callback of entries) callback() } } } test('gate and upload validation reject unverified, readonly, oversized, missing time and unknown model', () => { const file = { name: 'test.wav', size: 100 } assert.equal(state.uploadIssue(file, '2026-09-29 09:00:00', 'qwen', caps(), false), '') for (const options of [ { ...caps(), enabled: false }, { ...caps(), audio_verified: false }, { ...caps(), can_upload: false } ]) assert.match(state.uploadIssue(file, '2026-09-29 09:00:00', 'qwen', options, false), /不可上传/) assert.match(state.uploadIssue(file, '2026-09-29 09:00:00', 'qwen', caps(), true), /不可上传/) assert.match(state.uploadIssue({ ...file, size: 524288001 }, '2026-09-29 09:00:00', 'qwen', caps(), false), /超过/) assert.match(state.uploadIssue(file, '2026-02-30 09:00:00', 'qwen', caps(), false), /录音时间/) assert.match(state.uploadIssue(file, '', 'qwen', caps(), false), /录音时间/) assert.match(state.uploadIssue(file, '2026-09-29 09:00:00', 'other', caps(), false), /模型/) }) test('2MB chunks are sequential, acknowledged and stop immediately after stale generation', async () => { const chunks = [], progress = [] const file = new Blob([new Uint8Array(4194305)]) assert.equal(await state.uploadChunks(file, 2097152, async (index, blob) => { chunks.push([index, blob.size]); return { received: true } }, () => true, value => progress.push(value)), true) assert.deepEqual(chunks, [[0, 2097152], [1, 2097152], [2, 1]]) assert.equal(progress.at(-1), 100) const scope = state.createFollowupScope(), ticket = scope.capture() const sent = [] assert.equal(await state.uploadChunks(file, 2097152, async index => { sent.push(index); scope.invalidate(); return { received: true } }, () => scope.current(ticket), () => assert.fail('stale progress')), false) assert.deepEqual(sent, [0]) await assert.rejects(state.uploadChunks(file, 1024, async () => ({ received: true }), () => true, () => {}), /分片配置/) }) test('unresolved uncertainty, invalid dates/times and missing daily permission block selected items only', () => { assert.equal(state.reviewIssue(item(), true), '') assert.match(state.reviewIssue(item({ needs_review: true }), true), /核对/) assert.match(state.reviewIssue(item({ record_date: null }), true), /日期/) assert.match(state.reviewIssue(item({ record_date: '2026-02-30' }), true), /日期/) assert.match(state.reviewIssue(item({ record_time: '25:00:00' }), true), /时间/) assert.match(state.reviewIssue(item(), false), /权限/) assert.equal(state.reviewIssue(item({ selected: false, record_date: null, needs_review: true }), false), '') }) test('stale refresh preserves edited values and dates but only fresh immutable evidence/hash and clears all selections', () => { const local = item({ values: { fasting_blood_sugar: 7.1 }, record_date: '2026-09-28' }) const fresh = item({ evidence: [{ text: 'server-only' }], expected_hash: 'new', current_values: { fasting_blood_sugar: 8 }, conflict: true }) const [result] = state.rebaseReviewItems([local], [fresh]) assert.deepEqual(result.values, local.values) assert.equal(result.record_date, '2026-09-28') assert.equal(result.expected_hash, 'new') assert.deepEqual(result.evidence, fresh.evidence) assert.equal(result.current_values.fasting_blood_sugar, 8) assert.equal(result.selected, false) assert.equal(result.needs_review, true) }) test('same-day independent rows remain separate and option labels support numeric/string dictionaries', () => { const rows = state.cloneReviewItems([item(), item({ id: 'blood-2', record_time: '12:00:00' })]) assert.equal(rows.length, 2) assert.deepEqual(rows.map(row => row.record_time), ['08:00:00', '12:00:00']) assert.equal(state.fieldText('1', { type: 'select', options: [{ value: 1, label: '有' }] }), '有') }) test('API uses exact routes, version, header-auth blob and FormData with no automatic retry', async () => { const calls = [] let reply = { code: 1, data: { items: [] } } const request = Object.fromEntries(['get', 'post'].map(method => [method, async (config, options) => { calls.push({ method, config, options }); return reply }])) const api = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: request })) await api.followupAudioLists(10) await api.followupAudioSaveDraft({ id: 1, version: 4, items: [item()] }) reply = { code: 1, data: { index: 0, received: true } } await api.followupAudioUploadChunk('private-id', 0, new Blob(['chunk'])) reply = new Blob(['audio'], { type: 'audio/wav' }) assert.equal(await api.followupAudioBlob(1), reply) assert.deepEqual(calls.map(call => call.config.url), ['/tcm.followupAudio/lists', '/tcm.followupAudio/saveDraft', '/tcm.followupAudio/uploadChunk', '/tcm.followupAudio/audio']) assert.equal(calls[1].config.data.version, 4) assert.equal(calls[2].config.data.get('upload_id'), 'private-id') assert.equal(calls[2].config.data.get('file').size, 5) assert.deepEqual(calls[3].config.params, { id: 1 }) assert.equal(calls[3].config.responseType, 'blob') assert.ok(calls.every(call => call.options.isOpenRetry === false && call.options.isTransformResponse === false)) reply = { code: 0, msg: '版本已变化', data: { code: 'FOLLOWUP_AUDIO_STALE_REVIEW' } } await assert.rejects(api.followupAudioApply({ id: 1, version: 4, items: [item()] }), error => error.code === 'FOLLOWUP_AUDIO_STALE_REVIEW') reply = new Blob([JSON.stringify({ code: 0, msg: '未授权', data: {} })], { type: 'application/json' }) await assert.rejects(api.followupAudioBlob(1), /未授权/) }) test('all new Vue scripts/templates/styles and integrated diagnosis editor compile', () => { for (const name of ['FollowupAudioField.vue', 'FollowupAudioReview.vue', 'FollowupAudioPanel.vue', '../edit.vue']) { const filename = path.join(componentDir, name) const { descriptor, errors } = parse(fs.readFileSync(filename, 'utf8'), { filename }) assert.deepEqual(errors, [], name) const script = compileScript(descriptor, { id: name }) assert.deepEqual(compileTemplate({ source: descriptor.template.content, filename, id: name, compilerOptions: { bindingMetadata: script.bindings } }).errors, [], name) for (const style of descriptor.styles) assert.deepEqual(compileStyle({ source: style.content, filename, id: name, scoped: style.scoped, preprocessLang: style.lang }).errors, [], name) } }) test('actual panel polls only visible tab and stops all timers/listeners on leave', async () => { const f = setupPanel({}, { active: false }) await settle(); assert.deepEqual(f.calls, []) f.props.active = true; await settle() assert.deepEqual(f.calls, [['lists', 10]]) assert.equal(f.timers.size, 1) f.document.visibilityState = 'hidden'; f.listeners.get('visibilitychange')(); await settle() assert.equal(f.timers.size, 0) f.tick(); await settle(); assert.equal(f.calls.length, 1) f.document.visibilityState = 'visible'; f.listeners.get('visibilitychange')(); await settle() assert.equal(f.timers.size, 1) f.dispose(); assert.equal(f.timers.size, 0); assert.equal(f.listeners.size, 0) }) test('old patient and disposed detail callbacks never populate current patient state or emit applied', async () => { const old = deferred() const f = setupPanel({ followupAudioDetail: () => old.promise }) await settle() const opening = f.panel.openTask(1); await settle() f.props.diagnosisId = 20; await settle() old.resolve(task()); await opening; await settle() assert.equal(f.panel.detail.value, null) assert.deepEqual(f.panel.reviewItems.value, []) assert.deepEqual(f.events, []) f.dispose() }) test('upload actual panel sends actual recorded_at/model and chunks before complete/create', async () => { const f = setupPanel(); await settle() f.panel.file.value = new File([new Uint8Array(2097153)], 'synthetic.wav') f.panel.recordedAt.value = '2026-09-29 09:00:00'; f.panel.modelKey.value = 'openai' await f.panel.submitUpload(); await settle() assert.deepEqual(f.calls.filter(call => call[0] === 'chunk').map(call => call.slice(2)), [[0, 2097152], [1, 1]]) assert.deepEqual(f.calls.find(call => call[0] === 'create')[1], { diagnosis_id: 10, upload_id: 'private-id', recorded_at: '2026-09-29 09:00:00', model_key: 'openai', channel_roles: 'unconfirmed' }) assert.ok(f.calls.findIndex(call => call[0] === 'complete') < f.calls.findIndex(call => call[0] === 'create')) assert.equal(f.panel.createUncertain.value, false) f.dispose() }) test('unverified and view-only panels never upload, save, retry or apply even through method calls', async () => { for (const patch of [{ viewOnly: true }, { capabilities: { ...caps(), audio_verified: false, can_apply: false } }]) { const f = setupPanel({}, patch); await settle(); await f.panel.openTask(1) f.panel.file.value = new File(['test'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00' await f.panel.submitUpload(); await f.panel.saveDraft(); await f.panel.applySelected(); await f.panel.retryTask() assert.ok(f.calls.every(call => ['lists', 'detail'].includes(call[0]))) f.dispose() } }) test('dirty basic form and unresolved date prevent actual apply, save retains version and immutable evidence', async () => { const f = setupPanel({}, { basicDirty: true }); await settle(); await f.panel.openTask(1) await f.panel.applySelected(); assert.ok(!f.calls.some(call => call[0] === 'apply')) f.props.basicDirty = false f.panel.reviewItems.value[0].record_date = null await f.panel.applySelected(); assert.ok(!f.calls.some(call => call[0] === 'apply')) await f.panel.saveDraft() const payload = f.calls.find(call => call[0] === 'save')[1] assert.equal(payload.version, 1) assert.equal(payload.items[0].expected_hash, 'immutable-hash') assert.deepEqual(payload.items[0].evidence, item().evidence) f.dispose() }) test('one apply sends every item atomically once and emits diagnosis refresh only after observed success', async () => { let applied = false const f = setupPanel({ followupAudioDetail: async id => task(id, { status: applied ? 'applied' : 'review', items: [item(), item({ id: 'blood-2', record_time: '12:00:00' })] }), followupAudioApply: async payload => { f.calls.push(['apply', payload]); applied = true; return { id: 1, status: 'applied', applied_items: [] } } }) await settle(); await f.panel.openTask(1); await f.panel.applySelected(); await f.panel.applySelected() const requests = f.calls.filter(call => call[0] === 'apply') assert.equal(requests.length, 1); assert.equal(requests[0][1].items.length, 2) assert.deepEqual(f.events, [['applied', 10]]) f.dispose() }) test('stale save refreshes current snapshot, preserves local edits, and never silently resubmits', async () => { let stale = false const f = setupPanel({ followupAudioDetail: async id => task(id, { version: stale ? 2 : 1, items: [item({ expected_hash: stale ? 'fresh-hash' : 'old-hash' })] }), followupAudioSaveDraft: async payload => { f.calls.push(['save', payload]); stale = true; throw new apiModule.FollowupAudioError('版本已变化', 'FOLLOWUP_AUDIO_STALE_REVIEW') } }) await settle(); await f.panel.openTask(1) f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.9 await f.panel.saveDraft() assert.equal(f.panel.detail.value.version, 2) assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 7.9) assert.equal(f.panel.reviewItems.value[0].expected_hash, 'fresh-hash') assert.equal(f.panel.reviewItems.value[0].selected, false) assert.equal(f.panel.reviewItems.value[0].needs_review, true) assert.equal(f.calls.filter(call => call[0] === 'save').length, 1) f.dispose() }) test('authenticated audio object URLs revoked on hide and stale blobs never create object URLs', async () => { const pending = deferred() let slow = false const f = setupPanel({ followupAudioBlob: async () => slow ? pending.promise : new Blob(['synthetic'], { type: 'audio/wav' }) }) await settle(); await f.panel.openTask(1); await f.panel.loadAudio() assert.equal(f.urls.length, 1) f.props.active = false; await settle(); assert.deepEqual(f.revoked, f.urls) f.props.active = true; await settle(); slow = true const loading = f.panel.loadAudio(); await settle(); f.props.diagnosisId = 20; await settle() pending.resolve(new Blob(['old patient'])); await loading assert.equal(f.urls.length, 1) assert.equal(f.panel.audioUrl.value, '') f.dispose() }) test('editor capability gate, dirty guard and successful apply hook wire diagnosis plus daily refresh', () => { const source = fs.readFileSync(path.join(componentDir, '../edit.vue'), 'utf8') assert.match(source, /v-if="followupCapabilities\?\.enabled && formData.id"/) assert.match(source, /:before-leave="beforeTabLeave"/) assert.match(source, /next === 'followupAudio' && basicDirty.value/) assert.match(source, /await loadDiagnosisDetailIntoForm\(diagnosisId, true\)/) assert.match(source, /await dailyMatrixRef.value\?\.refresh\?\.\(\)/) assert.match(source, /if \(preserveDirty && basicDirty.value\)/) assert.match(source, /generation !== diagnosisGeneration \|\| !visible.value/) }) test('successful save with review_refreshed preserves unsent edits and requires explicit re-review', async () => { const f = setupPanel({ followupAudioSaveDraft: async () => task(1, { version: 2, review_refreshed: true, items: [item({ expected_hash: 'fresh-hash', selected: false, needs_review: true })] }) }) await settle(); await f.panel.openTask(1) f.panel.reviewItems.value[0].values.fasting_blood_sugar = 9.2 await f.panel.saveDraft() assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 9.2) assert.equal(f.panel.reviewItems.value[0].expected_hash, 'fresh-hash') assert.equal(f.panel.reviewItems.value[0].selected, false) assert.equal(f.panel.reviewItems.value[0].needs_review, true) assert.match(f.panel.errorMessage.value, /版本已刷新/) assert.equal(f.notices.filter(notice => notice[0] === 'success').length, 0) f.dispose() }) test('model defaults to first verified profile, including OpenAI-only and no verified profiles', async () => { const f = setupPanel({}, { capabilities: { ...caps(), models: [{ value: 'openai', label: 'OpenAI' }] } }) await settle() assert.equal(f.panel.modelKey.value, 'openai') f.props.capabilities = { ...caps(), models: [], audio_verified: false, can_upload: false } await settle() assert.equal(f.panel.modelKey.value, '') assert.equal(f.panel.canUpload.value, false) f.dispose() }) test('actual diagnosis editor preserves dirty form, refreshes diagnosis + daily after apply, and ignores old patient load', async () => { const warnings = [], events = [], pending = deferred(), saving = deferred() let detailCalls = 0, dailyRefreshes = 0 const row = (id, remark = '') => ({ id: String(id), patient_id: String(id + 100), patient_name: '合成患者', phone: '13800000000', id_card: '', remark, diabetes_discovery_year: '', show_card: 1 }) const filename = path.join(componentDir, '../edit.vue') const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor const script = compileScript(descriptor, { id: 'editor-runtime' }) const module = execute(script.content, name => { if (name === 'vue') return { ...vue, onBeforeUnmount() {} } if (name === '@/api/tcm') return { tcmDiagnosisEdit: () => saving.promise, tcmDiagnosisDetail: async ({ id }) => { detailCalls++; return id === 99 ? pending.promise : row(id, detailCalls > 1 ? '已采纳' : '') } } if (name === '@/api/app') return { getDictData: async () => ({}) } if (name === '@/api/followupAudio') return { followupAudioCapabilities: async () => caps() } if (name === '@/utils/feedback') return { default: { msgWarning: msg => warnings.push(msg) } } if (name === '@/utils/perm') return { hasPermission: () => true } if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 1 } }) } if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) } if (name === 'element-plus') return { ElMessage: { warning() {} } } if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {} if (name.endsWith('.vue')) return { default: {} } throw new Error(`Unexpected editor import: ${name}`) }) const scope = vue.effectScope() try { const editor = scope.run(() => module.default.setup({}, { expose() {}, emit: (...args) => events.push(args) })) await editor.open('edit', 10); await settle() assert.equal(editor.basicDirty.value, false) editor.formData.value.remark = '尚未保存' assert.equal(await editor.beforeTabLeave('followupAudio', 'basic'), false) assert.equal(editor.formData.value.remark, '尚未保存') assert.equal(editor.canApplyFollowup(), false) editor.dailyMatrixRef.value = { refresh: async () => { dailyRefreshes++ } } await editor.handleFollowupApplied(10) assert.equal(editor.formData.value.remark, '尚未保存') assert.equal(dailyRefreshes, 1) await editor.open('edit', 20) await editor.handleFollowupApplied(20) assert.equal(editor.formData.value.remark, '已采纳') assert.equal(editor.basicDirty.value, false) assert.equal(dailyRefreshes, 2) const oldLoad = editor.open('edit', 99); await settle() await editor.open('edit', 30) pending.resolve(row(99, '旧患者')) await oldLoad assert.equal(editor.formData.value.id, '30') assert.notEqual(editor.formData.value.remark, '旧患者') assert.ok(warnings.length >= 2) editor.formData.value.remark = '提交时的内容' const save = editor.handleSubmit(); await settle() editor.formData.value.remark = '提交期间继续输入' saving.resolve({}) await save assert.equal(editor.basicDirty.value, true) assert.equal(await editor.beforeTabLeave('followupAudio', 'basic'), false) assert.equal(editor.formData.value.remark, '提交期间继续输入') } finally { scope.stop() } }) test('changed real-row target and changed record date require saving a fresh snapshot before apply', async () => { assert.equal(state.changedReviewTarget([item()], [item()]), false) assert.equal(state.changedReviewTarget([item({ target_id: 7 })], [item()]), true) assert.equal(state.changedReviewTarget([item({ record_date: '2026-09-28' })], [item()]), true) const f = setupPanel(); await settle(); await f.panel.openTask(1) f.panel.reviewItems.value[0].target_id = 7 await f.panel.applySelected() assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0) assert.match(f.panel.applyIssue.value, /先保存审核草稿/) f.dispose() }) test('read-only identity fields cannot be selected or applied, including mixed editable/read-only items', async () => { const catalog = caps().fields catalog.diagnosis = [{ key: 'phone', label: '手机号', type: 'text', readonly: true }, { key: 'remark', label: '病史补充', type: 'text' }] const identity = item({ kind: 'diagnosis', values: { phone: '13800000000' } }) assert.equal(state.hasEditableReviewFields(identity, catalog), false) assert.match(state.reviewFieldIssue(identity, catalog), /不可编辑/) const mixed = { ...identity, values: { ...identity.values, remark: '需补充' } } assert.equal(state.hasEditableReviewFields(mixed, catalog), true) assert.match(state.reviewFieldIssue(mixed, catalog), /手机号/) const f = setupPanel({ followupAudioDetail: async () => task(1, { fields: catalog, items: [mixed] }) }) await settle(); await f.panel.openTask(1); await f.panel.applySelected() assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0) delete f.panel.reviewItems.value[0].values.phone assert.equal(f.panel.applyIssue.value, '') f.dispose() const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8') assert.match(source, /:disabled="disabled \|\| \(!row.selected && selectionBlocked\(row\)\)"/) assert.match(source, /!hasEditableReviewFields\(item, props.fields\)/) }) test('accepted audio extensions match backend and include AMR, not video or unsupported containers', () => { for (const ext of ['mp3', 'M4A', 'wav', 'amr']) assert.equal(state.uploadIssue({ name: `test.${ext}`, size: 100 }, '2026-09-29 09:00:00', 'qwen', caps(), false), '') for (const ext of ['mp4', 'webm', 'flac', 'ogg', 'aac']) assert.match(state.uploadIssue({ name: `test.${ext}`, size: 100 }, '2026-09-29 09:00:00', 'qwen', caps(), false), /格式/) }) test('same-audio creation reuse opens original task and tells operator it was not resent', async () => { const f = setupPanel({ followupAudioCreate: async () => ({ task_id: 1, status: 'review', reused: true, reuse_message: '同一录音已有任务,不会重复调用模型' }) }) await settle(); f.panel.file.value = new File(['synthetic'], 'call.amr'); f.panel.recordedAt.value = '2026-09-29 09:00:00' await f.panel.submitUpload(); await settle() assert.equal(f.panel.selectedId.value, 1) assert.match(f.panel.uploadStage.value, /不会重复/) assert.ok(f.notices.some(([kind, message]) => kind === 'warning' && /不会重复/.test(message))) f.dispose() }) test('definitive validation error can be corrected, network-unknown create still blocks resend', async () => { for (const [error, uncertain] of [[new apiModule.FollowupAudioError('实际通话时间无效'), false], [new Error('network failed'), true]]) { const f = setupPanel({ followupAudioCreate: async () => { throw error } }) await settle(); f.panel.file.value = new File(['synthetic'], 'call.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00' await f.panel.submitUpload() assert.equal(f.panel.createUncertain.value, uncertain) f.dispose() } }) test('minute-precision API times and legacy second-precision times are valid time-picker values', () => { assert.equal(state.reviewTimeValue('08:00'), '08:00') assert.equal(state.reviewTimeValue('12:05:00'), '12:05') assert.equal(state.reviewTimeValue(null), null) assert.equal(state.reviewTimeValue('25:00'), null) const review = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8') assert.match(review, /:model-value="reviewTimeValue\(editor.record_time\)" value-format="HH:mm"/) }) function setupReview(propsPatch = {}) { const filename = path.join(componentDir, 'FollowupAudioReview.vue') const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor const script = compileScript(descriptor, { id: 'review-runtime' }) const module = execute(script.content, name => { if (name === 'vue') return vue if (name === './followupAudioState') return state if (name === './FollowupAudioField.vue') return { default: {} } throw new Error(`Unexpected review import: ${name}`) }) const props = vue.reactive({ items: [item()], fields: caps().fields, disabled: false, canDaily: true, applied: false, uncertainties: [], ...propsPatch }) const events = [], exposed = {} const scope = vue.effectScope() const review = scope.run(() => module.default.setup(props, { expose: value => Object.assign(exposed, value), emit: (event, ...args) => { events.push([event, ...args]); if (event === 'update:items') props.items = args[0] } })) return { review, props, events, exposed, dispose: () => scope.stop() } } test('task list/detail navigation protects unsaved changes and closes the task only after confirmation', async () => { let allow = false const f = setupPanel({}, {}, { confirm: async () => { if (!allow) throw new Error('cancel') } }) await settle() assert.equal(f.panel.selectedId.value, 0) assert.equal(f.panel.detail.value, null) await f.panel.openTask(1) f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.1 await f.panel.backToList() assert.equal(f.panel.selectedId.value, 1) assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 7.1) allow = true await f.panel.backToList() assert.equal(f.panel.selectedId.value, 0) assert.equal(f.panel.detail.value, null) assert.deepEqual(f.panel.reviewItems.value, []) assert.equal(f.panel.dirty.value, false) assert.equal(f.timers.size, 1) f.dispose() }) test('upload is closed by default, cannot close while sending, closes after creation and respects empty profiles', async () => { const f = setupPanel(); await settle() assert.equal(f.panel.uploadVisible.value, false) f.panel.openUpload(); assert.equal(f.panel.uploadVisible.value, true) f.panel.uploading.value = true; f.panel.closeUpload(); assert.equal(f.panel.uploadVisible.value, true) f.panel.uploading.value = false; f.panel.closeUpload(); assert.equal(f.panel.uploadVisible.value, false) f.panel.openUpload(); f.panel.file.value = new File(['synthetic'], 'call.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00' await f.panel.submitUpload() assert.equal(f.panel.uploadVisible.value, false) assert.equal(f.panel.selectedId.value, 2) f.props.capabilities = { ...caps(), models: [] } assert.equal(f.panel.canUpload.value, false) f.panel.openUpload(); assert.equal(f.panel.uploadVisible.value, false) f.dispose() }) test('review group counts and pending filter never change hidden selections or their payload', () => { const rows = [item({ id: 'diagnosis-1', kind: 'diagnosis', values: { remark: '补充病史' } }), item({ id: 'blood-1', selected: false, needs_review: true }), item({ id: 'blood-2', record_time: '12:00' })] const f = setupReview({ items: rows }) const original = JSON.stringify(f.props.items) assert.deepEqual(f.review.counts.value, { all: 3, diagnosis: 1, daily: 2, pending: 1, selected: 2 }) for (const [group, ids] of [['diagnosis', ['diagnosis-1']], ['daily', ['blood-1', 'blood-2']], ['pending', ['blood-1']], ['all', ['diagnosis-1', 'blood-1', 'blood-2']]]) { f.review.group.value = group assert.deepEqual(f.review.visibleItems.value.map(row => row.id), ids) assert.equal(f.review.counts.value.selected, 2) assert.equal(JSON.stringify(f.props.items), original) } assert.deepEqual(f.events, []) f.dispose() }) test('single-item modal drafts are isolated: cancel discards, save preserves immutable evidence, confirm selects', () => { const f = setupReview() assert.equal(f.review.editor.value, null) f.review.openReview('blood-1') f.review.setValue('fasting_blood_sugar', 7.8) assert.equal(f.props.items[0].values.fasting_blood_sugar, 6.5) assert.equal(f.exposed.hasUnsavedChanges(), true) f.review.cancelReview() assert.equal(f.props.items[0].values.fasting_blood_sugar, 6.5) assert.equal(f.exposed.hasUnsavedChanges(), false) assert.equal(f.events.length, 0) f.review.openReview('blood-1'); f.review.setValue('fasting_blood_sugar', 7.8); f.review.saveReview(false) assert.equal(f.props.items[0].values.fasting_blood_sugar, 7.8) assert.equal(f.props.items[0].selected, false) assert.equal(f.props.items[0].needs_review, true) assert.equal(f.props.items[0].expected_hash, 'immutable-hash') assert.deepEqual(f.props.items[0].evidence, item().evidence) f.review.openReview('blood-1'); f.review.confirmReview(true); f.review.saveReview(true) assert.equal(f.review.editor.value, null) assert.equal(f.props.items[0].selected, true) assert.equal(f.props.items[0].needs_review, false) f.dispose() }) test('pending selection opens one review modal and cannot be adopted until explicitly confirmed', () => { const f = setupReview({ items: [item({ needs_review: true, selected: false })] }) f.review.toggleSelection(f.props.items[0], true) assert.equal(f.review.editor.value.id, 'blood-1') assert.equal(f.props.items[0].selected, false) f.review.saveReview(true); assert.equal(f.props.items[0].selected, false) assert.equal(f.events.length, 0) f.review.confirmReview(true); f.review.saveReview(true) assert.equal(f.props.items[0].selected, true) f.dispose() }) test('review modal rejects locked fields and fake/deleted targets; changed dates require a fresh snapshot', () => { const fields = caps().fields fields.diagnosis.push({ key: 'phone', label: '手机号', type: 'text', readonly: true }) const f = setupReview({ fields, items: [item({ selected: false, candidates: [{ id: 7, values: { fasting_blood_sugar: 5.8 }, record_time: '08:30', time_period: null, time_estimated: false }, { id: 9, values: {}, record_time: null, time_period: null, time_estimated: false, delete_time: 1 }] }), item({ id: 'identity', kind: 'diagnosis', values: { phone: '13800000000' }, selected: false })] }) assert.equal(f.review.selectionBlocked(f.props.items[1]), true) f.review.toggleSelection(f.props.items[1], true) assert.equal(f.events.length, 0) f.review.openReview('identity'); f.review.setValue('phone', '13900000000') assert.equal(f.review.editor.value.values.phone, '13800000000') f.review.openReview('blood-1'); f.review.chooseTarget(99); assert.equal(f.review.editor.value.target_id, null) f.review.chooseTarget(9); assert.equal(f.review.editor.value.target_id, null) f.review.chooseTarget(7); assert.equal(f.review.editor.value.target_id, 7) assert.equal(f.review.targetChanged.value, true) f.review.confirmReview(true); f.review.saveReview(true) assert.equal(f.props.items[0].target_id, null) f.review.patchEditor({ record_date: '2026-09-28', target_id: null }) assert.deepEqual(f.review.editorCandidates.value, []) f.review.chooseTarget(7); assert.equal(f.review.editor.value.target_id, null) f.review.saveReview(false) assert.equal(f.props.items[0].record_date, '2026-09-28') assert.equal(f.props.items[0].selected, false) assert.equal(f.props.items[0].needs_review, true) f.dispose() }) test('server refresh during a modal edit keeps local content, uses fresh evidence, and cancels stale selection', async () => { const f = setupReview() f.review.openReview('blood-1'); f.review.setValue('fasting_blood_sugar', 8.2) f.props.items = [item({ expected_hash: 'fresh-hash', evidence: [{ text: 'fresh evidence' }], current_values: { fasting_blood_sugar: 7 } })] await settle() assert.equal(f.review.editor.value.values.fasting_blood_sugar, 8.2) assert.equal(f.review.editor.value.expected_hash, 'fresh-hash') assert.deepEqual(f.review.editor.value.evidence, [{ text: 'fresh evidence' }]) assert.equal(f.review.editor.value.selected, false) assert.equal(f.review.editor.value.needs_review, true) assert.equal(f.exposed.hasUnsavedChanges(), true) assert.match(f.review.editorNotice.value, /已保留/) f.dispose() }) test('panel leave guard includes unsaved modal edits; recording original is collapsed until playback', async () => { const f = setupPanel({}, {}, { confirm: async () => { throw new Error('cancel') } }) await settle(); await f.panel.openTask(1) const r = setupReview({ items: f.panel.reviewItems.value }) f.panel.reviewRef.value = r.exposed r.review.openReview('blood-1'); r.review.setValue('fasting_blood_sugar', 9) assert.equal(f.panel.dirty.value, true) assert.equal(await f.panel.confirmLeave(), false) r.review.cancelReview(); assert.equal(f.panel.dirty.value, false) assert.deepEqual(f.panel.detailSections.value, []) await f.panel.loadAudio(5000) assert.deepEqual(f.panel.detailSections.value, ['original']) await f.panel.backToList() assert.deepEqual(f.panel.detailSections.value, []) r.dispose(); f.dispose() }) test('applied review is immutable and clearly distinguishes written and omitted rows', () => { const f = setupReview({ applied: true, disabled: true, items: [item(), item({ id: 'omitted', selected: false, needs_review: true })] }) const original = JSON.stringify(f.props.items) assert.deepEqual(f.review.itemStatus(f.props.items[0]), { label: '已写入', type: 'success' }) assert.deepEqual(f.review.itemStatus(f.props.items[1]), { label: '未采纳', type: 'info' }) f.review.openReview('blood-1'); f.review.setValue('fasting_blood_sugar', 10); f.review.removeField('fasting_blood_sugar'); f.review.confirmReview(false); f.review.saveReview(true); f.review.toggleSelection(f.props.items[0], false) assert.equal(JSON.stringify(f.props.items), original) assert.deepEqual(f.events, []) f.dispose() }) test('readonly field renders plain human-readable text, not disabled controls', async () => { const filename = path.join(componentDir, 'FollowupAudioField.vue') const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor const script = compileScript(descriptor, { id: 'field-render', inlineTemplate: true }) const module = execute(script.content, name => name === 'vue' ? vue : name === './followupAudioState' ? state : (() => { throw new Error(`Unexpected field import: ${name}`) })()) const app = vue.createSSRApp(module.default, { field: { key: 'smoking', label: '吸烟', type: 'select', options: [{ label: '否', value: 0 }] }, modelValue: 0, readonly: true }) app.config.warnHandler = () => {} const html = await require('@vue/server-renderer').renderToString(app) assert.match(html, /readonly-value[^>]*>否<\/span>/) assert.doesNotMatch(html, / { const f = setupReview({ snapshotItems: [item()] }) f.review.openReview('blood-1') f.review.patchEditor({ record_date: '2026-09-28' }) f.review.saveReview(false) f.review.openReview('blood-1') assert.equal(f.review.targetChanged.value, true) assert.deepEqual(f.review.editorCandidates.value, []) f.review.confirmReview(true) f.review.saveReview(true) assert.equal(f.props.items[0].selected, false) f.props.snapshotItems = state.cloneReviewItems(f.props.items) assert.equal(f.review.targetChanged.value, false) f.review.saveReview(true) assert.equal(f.props.items[0].selected, true) f.dispose() }) test('unsaved modal edits cannot be bypassed by parent save/apply commands', async () => { const f = setupPanel(); await settle(); await f.panel.openTask(1) const r = setupReview({ items: f.panel.reviewItems.value }) f.panel.reviewRef.value = r.exposed r.review.openReview('blood-1'); r.review.setValue('fasting_blood_sugar', 9) await f.panel.saveDraft(); await f.panel.applySelected() assert.equal(f.calls.filter(call => call[0] === 'save' || call[0] === 'apply').length, 0) assert.equal(r.review.editor.value.values.fasting_blood_sugar, 9) assert.ok(f.notices.every(([, message]) => message.includes('单项修改'))) r.dispose(); f.dispose() }) test('narrow review tables keep the review action reachable and show each event date in its content cell', () => { const source=fs.readFileSync(path.join(componentDir,'FollowupAudioReview.vue'),'utf8') assert.match(source, /label="操作"[^>]*fixed="right"/) assert.match(source, /class="item-meta mobile-event-time"/) assert.match(source, /\.mobile-event-time \{ display: block; \}/) }) test('two-stage labels expose real stage and bounded chunk progress without declaring success', () => { for (const [stage, label] of [['transcribing', '正在转写'], ['extracting', '正在提炼'], ['validating', '正在校验']]) { const current = task(1, { status: 'running', stage, pipeline_progress: { transcribed: 2, total: 3 } }) assert.equal(state.followupTaskStatus(current), label) assert.equal(state.followupProgressText(current), `${label} · 转写片段 2 / 3;人工审核前不会写入业务记录。`) } for (const pipeline_progress of [undefined, { transcribed: 0, total: 0 }, { transcribed: 4, total: 3 }, { transcribed: -1, total: 3 }, { transcribed: 1.5, total: 3 }, { transcribed: 2, total: Infinity }]) { assert.doesNotMatch(state.followupProgressText(task(1, { status: 'running', stage: 'transcribing', pipeline_progress })), /转写片段/) } assert.match(state.followupProgressText(task(1, { status: 'running', stage: 'transcribing', pipeline_progress: { transcribed: 0, total: 3 } })), /0 \/ 3/) assert.equal(state.followupTaskStatus(task(1, { status: 'failed', stage: 'extracting' })), '处理失败') assert.equal(state.followupTaskStatus(task(1, { status: 'needs_reconciliation', stage: 'extracting' })), '结果待核对') assert.equal(state.followupTaskStatus(task(1, { status: 'running', stage: 'legacy_unknown' })), '正在分析') const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8') assert.match(source, /followupTaskStatus\(row\)/) assert.match(source, /followupProgressText\(detail\)/) }) test('failed extraction retains canonical ASR text visibly but blocks review writes and does not retry', async () => { const result = task(1, { status: 'failed', stage: 'extracting', transcript_available: true, transcript: '合成转写:今天早晨测了6.5。', summary: '', items: [], error_message: '提炼阶段失败', pipeline_progress: { transcribed: 3, total: 3 } }) const f = setupPanel({ followupAudioLists: async () => ({ items: [result] }), followupAudioDetail: async () => result }) await settle(); await f.panel.openTask(1) assert.equal(f.panel.detail.value.status, 'failed') assert.equal(f.panel.detail.value.transcript, result.transcript) assert.equal(f.panel.detail.value.error_message, '提炼阶段失败') assert.deepEqual(f.panel.detailSections.value, ['original']) assert.match(state.retainedTranscriptNotice(f.panel.detail.value), /不代表提炼成功/) assert.equal(f.panel.canEditReview.value, false) await f.panel.saveDraft(); await f.panel.applySelected(); await f.panel.retryTask() assert.equal(f.calls.filter(call => ['save', 'apply', 'retry'].includes(call[0])).length, 0) assert.equal(f.notices.filter(([kind]) => kind === 'success').length, 0) await f.panel.loadAudio() assert.equal(f.panel.audioUrl.value, 'blob:private-0') assert.deepEqual(f.calls.filter(call => call[0] === 'audio'), [['audio', 1]]) f.dispose() const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8') assert.match(source, /detail\.error_message \|\| detail\.status === 'failed'/) assert.match(source, /detail\.status === 'failed' \? 'error' : 'warning'/) assert.match(source, /detail\.transcript \|\| '暂无转录'/) }) test('poll transition to failed extraction exposes retained text; unknown upstream stays unretryable', async () => { let current = task(1, { status: 'running', stage: 'transcribing', transcript_available: false, transcript: '', items: [], pipeline_progress: { transcribed: 0, total: 3 } }) const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current }) await settle(); await f.panel.openTask(1) assert.deepEqual(f.panel.detailSections.value, []) current = { ...current, stage: 'extracting', transcript_available: true, transcript: '合成已完成转写', pipeline_progress: { transcribed: 3, total: 3 } } f.tick(); await settle() assert.equal(f.panel.detail.value.stage, 'extracting') assert.equal(f.panel.detail.value.transcript, current.transcript) current = { ...current, status: 'failed', error_message: '提炼失败' } f.tick(); await settle() assert.deepEqual(f.panel.detailSections.value, ['original']) f.panel.detailSections.value = [] await f.panel.refresh() assert.deepEqual(f.panel.detailSections.value, []) current = { ...current, status: 'needs_reconciliation', error_message: '提炼结果待核对', can_retry: false } f.tick(); await settle() assert.match(state.retainedTranscriptNotice(f.panel.detail.value), /不要重复发送/) await f.panel.retryTask() assert.equal(f.calls.filter(call => call[0] === 'retry').length, 0) f.dispose() for (const patch of [{ status: 'expired', transcript_available: true }, { status: 'failed', transcript_available: false }, { status: 'failed', transcript_available: true, transcript: '' }]) assert.equal(state.retainedTranscriptNotice(task(1, patch)), '') }) test('old-patient retained-transcript completion cannot expose text or reopen current detail', async () => { const pending = deferred() const f = setupPanel({ followupAudioDetail: async () => pending.promise }) await settle() const opening = f.panel.openTask(1); await settle() f.props.diagnosisId = 20; await settle() pending.resolve(task(1, { status: 'failed', stage: 'extracting', transcript_available: true, transcript: '旧患者合成文本' })) await opening; await settle() assert.equal(f.panel.detail.value, null) assert.equal(f.panel.selectedId.value, 0) assert.deepEqual(f.panel.detailSections.value, []) assert.deepEqual(f.events, []) f.dispose() }) test('segment playback uses trusted original-audio boundaries and never claims word-level alignment', () => { const quote = { text: '今天早晨测了6.5', segment_id: 'chunk-2', start_ms: 60000, end_ms: 120000, position_type: 'segment' } assert.equal(state.evidencePlaybackLabel(quote), '回听对话 1:00–2:00') assert.equal(state.evidencePlaybackLabel(item().evidence[0]), '播放 0:05') for (const patch of [{ start_ms: -1 }, { start_ms: Infinity }, { end_ms: NaN }, { end_ms: 60000 }, { segment_id: '' }, { end_ms: undefined }]) assert.equal(state.evidencePlaybackLabel({ ...quote, ...patch }), '') const f = setupReview({ items: [item({ evidence: [quote] })] }) f.review.openReview('blood-1') f.review.seekEvidence(f.review.editor.value.evidence[0]) assert.deepEqual(f.events, [['seek', 60000]]) f.review.seekEvidence({ ...quote, start_ms: -1 }) assert.equal(f.events.length, 1) f.dispose() const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8') assert.match(source, /不是原话的逐字时间戳/) assert.match(source, /evidencePlaybackLabel\(quote\)/) }) test('segment identities and bounds remain immutable through single-item edit, draft and apply', async () => { const evidence = [{ text: '今天早晨测了6.5', segment_id: 'chunk-2', start_ms: 60000, end_ms: 120000, position_type: 'segment' }] const original = item({ evidence }) const review = setupReview({ items: [original] }) review.review.openReview('blood-1'); review.review.setValue('fasting_blood_sugar', 7) review.review.editor.value.evidence[0] = { text: '不可保存的伪造证据', segment_id: 'fake', start_ms: 0, end_ms: 1, position_type: 'segment' } review.review.confirmReview(true); review.review.saveReview(true) assert.deepEqual(review.props.items[0].evidence, evidence) const f = setupPanel({ followupAudioDetail: async () => task(1, { items: review.props.items }) }) await settle(); await f.panel.openTask(1); await f.panel.saveDraft(); await f.panel.applySelected() for (const action of ['save', 'apply']) { const sent = f.calls.find(call => call[0] === action) assert.ok(sent, action) assert.deepEqual(sent[1].items[0].evidence, evidence) assert.equal(sent[1].items[0].expected_hash, 'immutable-hash') } const freshEvidence = [{ ...evidence[0], segment_id: 'server-refreshed' }] assert.deepEqual(state.rebaseReviewItems([original], [item({ evidence: freshEvidence })])[0].evidence, freshEvidence) f.dispose(); review.dispose() }) test('selected undated or uncertain segment-backed suggestions remain blocked from apply', async () => { const evidence = [{ text: '最近测了6.5', segment_id: 'chunk-1', start_ms: 0, end_ms: 60000, position_type: 'segment' }] for (const patch of [{ record_date: null, date_text: '最近' }, { needs_review: true }]) { const f = setupPanel({ followupAudioDetail: async () => task(1, { items: [item({ evidence, ...patch })] }) }) await settle(); await f.panel.openTask(1); await f.panel.applySelected() assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0) assert.notEqual(f.panel.applyIssue.value, '') f.dispose() } }) test('review cannot offer or add catalog fields absent from the item, including a polluted draft snapshot', () => { const fields = caps().fields fields.blood.push({ key: 'systolic_pressure', label: '收缩压', type: 'number' }) const f = setupReview({ fields, snapshotItems: [item({ values: { fasting_blood_sugar: 6.5, systolic_pressure: 120 } })] }) f.review.openReview('blood-1') assert.equal(f.review.availableFields, undefined) assert.equal(f.review.addField, undefined) f.review.setValue('systolic_pressure', 120) assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 }) f.review.patchEditor({ values: { fasting_blood_sugar: 7, systolic_pressure: 120 } }) assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 }) f.review.setValue('fasting_blood_sugar', 7) f.review.confirmReview(true); f.review.saveReview(true) assert.deepEqual(f.props.items[0].values, { fasting_blood_sugar: 7 }) assert.deepEqual(f.props.items[0].evidence, item().evidence) const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8') assert.doesNotMatch(source, /placeholder="补充字段"|availableFields|function addField|class="add-field"/) assert.match(source, /新增内容请返回原诊单编辑人工补录/) f.dispose() }) test('removal can be cancelled but saved drafts cannot expand their field scope on reopening', () => { const fields = caps().fields fields.blood.push({ key: 'systolic_pressure', label: '收缩压', type: 'number' }) const original = item({ values: { fasting_blood_sugar: 6.5, systolic_pressure: 120 } }) const f = setupReview({ fields, items: [original], snapshotItems: [original] }) f.review.openReview('blood-1'); f.review.removeField('systolic_pressure') f.review.setValue('systolic_pressure', 125) assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 }) f.review.cancelReview(); f.review.openReview('blood-1') assert.deepEqual(f.review.editor.value.values, original.values) f.review.removeField('systolic_pressure'); f.review.saveReview(false) f.review.openReview('blood-1'); f.review.setValue('systolic_pressure', 125) assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 }) f.review.removeField('fasting_blood_sugar') assert.deepEqual(f.review.editor.value.values, { fasting_blood_sugar: 6.5 }, 'last field cannot be removed into an unsaveable empty draft') assert.equal(f.props.items[0].selected, false) f.dispose() }) test('actual review payload remains within production mergeItems proposal scope; server rejects a forged extra field', () => { const fields = caps().fields fields.blood.push({ key: 'systolic_pressure', label: '收缩压', type: 'number' }) const original = item({ record_time: '08:00' }) const f = setupReview({ fields, items: [original] }) f.review.openReview('blood-1'); f.review.setValue('systolic_pressure', 120) f.review.setValue('fasting_blood_sugar', 7); f.review.confirmReview(true); f.review.saveReview(true) const submitted = f.events.find(([name]) => name === 'update:items')[1] const serviceDir = path.resolve(root, '../server/app/common/service/followupaudio') const script = ` require $argv[1] . '/FollowupAudioFields.php'; require $argv[1] . '/FollowupAudioPolicy.php'; require $argv[1] . '/FollowupAudioApply.php'; $input = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR); $class = 'app\\\\common\\\\service\\\\followupaudio\\\\FollowupAudioApply'; $merged = $class::mergeItems($input['stored'], $input['submitted'], $input['source']); $forged = $input['submitted']; $forged[0]['values']['systolic_pressure'] = 120; try { $class::mergeItems($input['stored'], $forged, $input['source']); $error = null; } catch (DomainException $exception) { $error = $exception->getMessage(); } echo json_encode(['merged_values' => $merged[0]['values'], 'evidence' => $merged[0]['evidence'], 'forged_error' => $error], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR); ` const temp = fs.mkdtempSync(path.join(require('node:os').tmpdir(), 'followup-merge-test-')) let result try { const filename = path.join(temp, 'merge.php') fs.writeFileSync(filename, ` { const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8') assert.match(source, /
/)
})
test('stereo role draft is dirty, blocks apply and leave, saves exact role/version then requires item re-review', async () => {
let current = stereoTask()
const saves = []
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
saves.push(payload)
current = { ...current, version: payload.version + 1, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
return current
} }, {}, { confirm: async () => { throw new Error('cancel') } })
await settle(); await f.panel.openTask(1)
f.panel.setChannelRoles('left_service')
assert.equal(f.panel.roleDirty.value, true)
assert.equal(f.panel.dirty.value, true)
assert.equal(f.panel.savedChannelRoles.value, 'unconfirmed', 'unsaved role never labels a transcript as confirmed')
assert.equal(await f.panel.confirmLeave(), false)
await f.panel.applySelected(); assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
await f.panel.saveDraft()
assert.equal(saves.length, 1)
assert.equal(saves[0].channel_roles, 'left_service')
assert.equal(saves[0].version, 1)
assert.equal(f.panel.roleDirty.value, false)
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
assert.equal(f.panel.selectedCount.value, 0)
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
assert.match(f.notices.at(-1)[1], /不会自动重新提炼/)
f.dispose()
})
test('poll and stale save preserve unsaved role; refreshed save installs authoritative role and clears selections', async () => {
let current = stereoTask()
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current,
followupAudioSaveDraft: async () => { throw new apiModule.FollowupAudioError('版本已变化', 'FOLLOWUP_AUDIO_STALE_REVIEW') } })
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('right_service')
current = { ...current, version: 2, channel_roles: 'left_service' }
f.tick(); await settle()
assert.equal(f.panel.channelRoles.value, 'right_service')
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
assert.equal(f.panel.roleDirty.value, true)
assert.equal(f.panel.reviewItems.value[0].selected, false)
await f.panel.saveDraft()
assert.equal(f.panel.channelRoles.value, 'right_service')
assert.equal(f.panel.roleDirty.value, true)
f.dispose()
const g = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: async payload => stereoTask({ version: 2, channel_roles: payload.channel_roles, review_refreshed: true, items: [item({ selected: false, needs_review: true })] }) })
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('right_service'); await g.panel.saveDraft()
assert.equal(g.panel.channelRoles.value, 'right_service')
assert.equal(g.panel.savedChannelRoles.value, 'right_service')
assert.equal(g.panel.roleDirty.value, false)
assert.equal(g.panel.reviewItems.value[0].selected, false)
g.dispose()
})
test('stereo roles honor permission/status gates and mono saves omit channel_roles', async () => {
for (const props of [{ viewOnly: true }, { capabilities: { ...caps(), can_apply: false } }]) {
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, props)
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(f.calls.filter(call => call[0] === 'save').length, 0)
f.dispose()
}
const f = setupPanel()
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
assert.equal(f.panel.hasStereo.value, false)
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(Object.hasOwn(f.calls.find(call => call[0] === 'save')[1], 'channel_roles'), false)
f.dispose()
const g = setupPanel({ followupAudioDetail: async () => stereoTask({ status: 'applied', channel_roles: 'left_service' }) })
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('right_service')
assert.equal(g.panel.channelRoles.value, 'left_service')
g.dispose()
})
test('permission or unsaved roles changed during apply confirmation prevent sending apply', async () => {
for (const change of [f => f.panel.setChannelRoles('right_service'), f => { f.props.viewOnly = true }]) {
const confirm = deferred()
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'left_service' }) }, {}, { confirm: () => confirm.promise })
await settle(); await f.panel.openTask(1)
const applying = f.panel.applySelected(); await settle(); change(f); confirm.resolve(true); await applying
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
f.dispose()
}
})
test('old stereo role save/detail completions cannot populate a new patient or emit apply', async () => {
const pending = deferred()
const f = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: async () => pending.promise })
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service')
const saving = f.panel.saveDraft(); await settle()
f.props.diagnosisId = 20; await settle()
pending.resolve(stereoTask({ version: 2, channel_roles: 'left_service' })); await saving
assert.equal(f.panel.detail.value, null)
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(f.panel.roleDirty.value, false)
assert.deepEqual(f.panel.transcriptSegments.value, [])
assert.deepEqual(f.events, [])
assert.deepEqual(f.notices, [])
f.dispose()
})
test('WebAudio real routing helper connects only chosen splitter output to both speakers, switches and disposes', async () => {
const f = fakeAudioContext()
const playback = playbackModule.createFollowupAudioPlayback(f.element, () => f.context)
assert.equal(await playback.select('original'), true)
assert.equal(f.events.filter(event => event[0] === 'media').length, 0, 'legacy original playback does not require Web Audio')
for (const channel of [0, 1]) {
assert.equal(await playback.select(channel), true)
const media = f.nodes.find(node => node.name === 'media'), split = f.nodes.find(node => node.name === 'splitter'), merge = f.nodes.find(node => node.name === 'merger')
assert.deepEqual(media.edges.map(edge => edge.target.name), ['splitter'])
assert.deepEqual(split.edges.map(({ target, output, input }) => [target.name, output, input]), [['merger', channel, 0], ['merger', channel, 1]])
assert.deepEqual(merge.edges.map(edge => edge.target.name), ['destination'])
}
assert.equal(f.events.filter(event => event[0] === 'media').length, 1)
assert.equal(await playback.select('original'), true)
assert.deepEqual(f.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
assert.deepEqual(f.nodes.find(node => node.name === 'splitter').edges, [])
playback.dispose(); playback.dispose()
assert.ok(f.nodes.every(node => node.edges.length === 0))
assert.equal(f.events.filter(event => event[0] === 'close').length, 1)
assert.equal(await playback.select(0), false)
})
test('WebAudio unsupported/resume failure never claims isolated playback and pending resume is fenced after dispose', async () => {
const unsupported = fakeAudioContext()
const missing = playbackModule.createFollowupAudioPlayback(unsupported.element, () => null)
await assert.rejects(missing.select(0), /无法隔离声道/)
assert.equal(unsupported.element.paused, true)
assert.equal(await missing.select('original'), true)
missing.dispose()
const failed = fakeAudioContext({ resume: async () => { throw new Error('NotAllowedError') } })
const route = playbackModule.createFollowupAudioPlayback(failed.element, () => failed.context)
await assert.rejects(route.select(1), /无法隔离声道/)
assert.deepEqual(failed.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
assert.equal(failed.events.filter(event => event[0] === 'play').length, 0)
route.dispose()
const resumed = deferred(), pending = fakeAudioContext({ resume: () => resumed.promise })
const delayed = playbackModule.createFollowupAudioPlayback(pending.element, () => pending.context)
const selecting = delayed.select(1); delayed.dispose(); resumed.resolve(); assert.equal(await selecting, false)
assert.ok(pending.nodes.every(node => node.edges.length === 0))
})
test('actual panel selects evidence channel, seeks original timeline and releases routing on hide and task switch', async () => {
const audio = fakeAudioContext()
const f = setupPanel({ followupAudioDetail: async id => stereoTask({ id, channel_roles: 'left_service' }) }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
await f.panel.loadAudio(17000, 1)
assert.equal(f.panel.playbackChannel.value, 1)
assert.equal(audio.element.currentTime, 17)
assert.equal(audio.element.paused, false)
assert.deepEqual(f.calls.filter(call => call[0] === 'audio'), [['audio', 1]])
assert.equal(f.panel.playbackError.value, '')
await f.panel.playChannel(0)
assert.equal(f.panel.playbackChannel.value, 0)
assert.equal(f.calls.filter(call => call[0] === 'audio').length, 1)
f.props.active = false; await settle()
assert.equal(audio.element.paused, true)
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
assert.equal(f.panel.playbackChannel.value, 'original')
assert.equal(f.panel.audioUrl.value, '')
assert.deepEqual(f.revoked, ['blob:private-0'])
f.props.active = true; await settle(); await f.panel.openTask(2)
assert.equal(f.panel.selectedId.value, 2)
assert.equal(f.panel.channelLoading.value, false)
f.dispose()
})
test('actual panel reports unavailable isolation, keeps original playback and never falls back to mixed autoplay', async () => {
const audio = fakeAudioContext()
const unsupported = { createFollowupAudioPlayback: element => playbackModule.createFollowupAudioPlayback(element, () => null) }
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: unsupported })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
await f.panel.loadAudio(1000, 0)
assert.match(f.panel.playbackError.value, /无法隔离声道/)
assert.equal(f.panel.playbackChannel.value, 'original')
assert.equal(audio.element.paused, true)
assert.equal(audio.events.filter(event => event[0] === 'play').length, 0)
await f.panel.playChannel('original')
assert.equal(f.panel.playbackError.value, '')
assert.equal(audio.element.paused, false)
f.dispose()
const mono = setupPanel()
await settle(); await mono.panel.openTask(1); mono.panel.audioElement.value = audio.element
await mono.panel.loadAudio(1000, 1)
assert.match(mono.panel.playbackError.value, /未取得可验证的双声道/)
assert.equal(mono.calls.filter(call => call[0] === 'audio').length, 0)
mono.dispose()
})
test('late stereo blob and routing resume do not play or relabel another patient', async () => {
const pending = deferred(), audio = fakeAudioContext({ resume: () => pending.promise })
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
const play = f.panel.loadAudio(5000, 1); await settle()
assert.equal(f.panel.channelLoading.value, true)
f.props.diagnosisId = 20; await settle(); pending.resolve(); await play
assert.equal(f.panel.audioUrl.value, '')
assert.equal(f.panel.playbackChannel.value, 'original')
assert.equal(f.panel.channelLoading.value, false)
assert.equal(audio.events.filter(event => event[0] === 'play').length, 0)
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
f.dispose()
const blob = deferred(), g = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioBlob: async () => blob.promise }, {}, { playback: audio.playback })
await settle(); await g.panel.openTask(1); g.panel.audioElement.value = audio.element
const loading = g.panel.loadAudio(5000, 0); await settle(); g.props.diagnosisId = 30; await settle()
blob.resolve(new Blob(['synthetic'])); await loading
assert.deepEqual(g.urls, [])
assert.equal(g.panel.detail.value, null)
g.dispose()
})
test('channel evidence stays immutable while seek defaults to full dialogue', async () => {
const quote = { text: '合成右侧原话', segment_id: 'right-1', start_ms: 15000, end_ms: 30000, position_type: 'segment', channel: 1 }
const f = setupReview({ items: [item({ evidence: [quote] })], channelRoles: 'unconfirmed' })
f.review.openReview('blood-1'); f.review.seekEvidence(f.review.editor.value.evidence[0])
assert.deepEqual(f.events, [['seek', 15000]])
assert.equal(state.evidencePlaybackLabel(quote), '回听对话 0:15–0:30')
f.review.editor.value.evidence[0].channel = 0
f.review.setValue('fasting_blood_sugar', 7); f.review.confirmReview(true); f.review.saveReview(true)
assert.deepEqual(f.props.items[0].evidence, [quote])
const panel = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'right_service', items: f.props.items }) })
await settle(); await panel.panel.openTask(1); await panel.panel.saveDraft()
assert.equal(panel.calls.find(call => call[0] === 'save')[1].items[0].evidence[0].channel, 1)
f.dispose(); panel.dispose()
})
test('API forwards optional channel_roles only in draft and stereo apply uses server roles after explicit re-review', async () => {
const requests = []
const api = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: { post: async (config, options) => { requests.push({ config, options }); return { code: 1, data: stereoTask({ channel_roles: 'left_service' }) } } } }))
await api.followupAudioSaveDraft({ id: 1, version: 1, items: [item()], channel_roles: 'left_service' })
assert.equal(requests[0].config.url, '/tcm.followupAudio/saveDraft')
assert.equal(requests[0].config.data.channel_roles, 'left_service')
assert.equal(requests[0].options.isOpenRetry, false)
let current = stereoTask()
const applied = []
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
current = { ...current, version: 2, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
return current
}, followupAudioApply: async payload => { applied.push(payload); current = { ...current, status: 'applied', items: payload.items }; return { id: 1, status: 'applied', applied_items: [] } } })
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
await f.panel.applySelected(); assert.equal(applied.length, 0)
const review = setupReview({ items: f.panel.reviewItems.value })
review.review.openReview('blood-1'); review.review.confirmReview(true); review.review.saveReview(true)
f.panel.reviewItems.value = review.props.items
await f.panel.applySelected()
assert.equal(applied.length, 1)
assert.equal(applied[0].version, 2)
assert.equal(Object.hasOwn(applied[0], 'channel_roles'), false, 'apply uses authoritative server-saved role mapping')
assert.equal(f.panel.detail.value.status, 'applied')
assert.deepEqual(f.events, [['applied', 10]])
review.dispose(); f.dispose()
})
test('rapid opposing evidence seeks use only the newest channel and original-audio reload closes the old graph', async () => {
const resume = deferred(), audio = fakeAudioContext({ resume: () => resume.promise })
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
const first = f.panel.loadAudio(2000, 0); await settle()
const second = f.panel.loadAudio(9000, 1); await settle(); resume.resolve(); await Promise.all([first, second])
assert.equal(f.panel.playbackChannel.value, 1)
assert.equal(audio.element.currentTime, 9)
assert.equal(audio.events.filter(event => event[0] === 'play').length, 1)
assert.deepEqual(audio.nodes.find(node => node.name === 'splitter').edges.map(edge => edge.output), [1, 1])
await f.panel.reloadAudio()
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
assert.equal(f.panel.playbackChannel.value, 'original')
assert.deepEqual(f.revoked, ['blob:private-0'])
assert.equal(f.calls.filter(call => call[0] === 'audio').length, 2)
f.dispose()
})
test('task switch and observed audio expiry both close live stereo routing and revoke the authorized blob', async () => {
for (const transition of ['task', 'expired']) {
const audio = fakeAudioContext()
let current = stereoTask({ channel_roles: 'left_service' })
const f = setupPanel({ followupAudioDetail: async id => ({ ...current, id }), followupAudioLists: async () => ({ items: [current] }) }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element; await f.panel.loadAudio(1000, 1)
if (transition === 'task') await f.panel.openTask(2)
else {
current = { ...current, version: 2, status: 'expired', audio_available: false, transcript: '', transcript_segments: [] }
f.tick(); await settle()
assert.deepEqual(f.panel.transcriptSegments.value, [])
}
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
assert.equal(audio.element.paused, true)
assert.deepEqual(f.revoked, ['blob:private-0'])
assert.equal(f.panel.audioUrl.value, '')
assert.equal(f.panel.playbackChannel.value, 'original')
f.dispose()
}
})
const previewCaps = (extra = {}) => ({ ...caps(), audio_verified: false, preview_only: true, preview_ready: true, can_review: true, can_apply: false, test_scope_allowed: true, ...extra })
test('preview readiness is independent of full verification and scope/readiness omissions fail closed', () => {
const recording = { name: 'synthetic.wav', size: 100 }
assert.equal(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', previewCaps(), false), '')
assert.equal(state.followupCanReview(previewCaps()), true)
assert.equal(state.followupCanApply(previewCaps({ can_apply: true, audio_verified: true })), false)
for (const patch of [{ enabled: false }, { preview_ready: false }, { preview_ready: undefined }, { test_scope_allowed: false }, { test_scope_allowed: undefined }, { can_upload: false }]) {
assert.match(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', previewCaps({ audio_verified: true, ...patch }), false), /不可上传/)
}
assert.equal(state.followupCanReview(previewCaps({ can_review: false, can_apply: true })), false)
assert.equal(state.followupCanReview(previewCaps({ can_review: undefined, can_apply: true })), false)
assert.equal(state.followupFeatureEnabled(previewCaps({ test_scope_allowed: false })), false)
const normal = { ...caps(), preview_only: false, preview_ready: false, can_review: false, test_scope_allowed: false }
assert.equal(state.followupAudioReady(normal), true)
assert.equal(state.followupCanReview(normal), true)
assert.equal(state.followupCanApply(normal), true)
assert.match(state.uploadIssue(recording, '2026-09-29 09:00:00', 'qwen', { ...normal, audio_verified: false, preview_ready: true }, false), /不可上传/)
for (const helper of [state.followupFeatureEnabled, state.followupAudioReady, state.followupCanReview, state.followupCanApply]) assert.equal(helper(null), false)
})
test('preview actual component can upload, view transcript and save draft with full verification false but never apply', async () => {
const f = setupPanel({ followupAudioDetail: async id => task(id, { preview_only: true }), followupAudioSaveDraft: async payload => {
f.calls.push(['save', payload]); return task(payload.id, { preview_only: true, version: payload.version + 1, items: payload.items })
} }, { capabilities: previewCaps() })
await settle()
assert.equal(f.panel.canUpload.value, true)
f.panel.openUpload(); f.panel.file.value = new File(['synthetic'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
await f.panel.submitUpload()
assert.ok(f.calls.some(call => call[0] === 'create'))
assert.equal(f.panel.detail.value.transcript, '测试原文')
assert.equal(f.props.capabilities.audio_verified, false)
assert.equal(f.panel.canEditReview.value, true)
assert.equal(f.panel.canApplyReview.value, false)
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'save').length, 1)
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
assert.match(f.panel.applyIssue.value, /测试预览/)
f.dispose()
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
assert.match(source, /v-if="previewOnly"[^>]+:title="capabilities.preview_only/)
assert.match(source, /]+@click="applySelected">确认写入/)
assert.match(source, /历史测试任务,仅供查看和审阅/)
})
test('preview roles and review_refreshed drafts remain editable without can_apply and preserve dirty work', async () => {
const current = stereoTask({ preview_only: true })
const sent = []
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
sent.push(payload)
return { ...current, version: 2, channel_roles: payload.channel_roles, review_refreshed: true, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
} }, { capabilities: previewCaps() }, { confirm: async () => { throw new Error('cancel') } })
await settle(); await f.panel.openTask(1)
f.panel.setChannelRoles('left_service'); f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.2
assert.equal(f.panel.dirty.value, true)
assert.equal(await f.panel.confirmLeave(), false)
await f.panel.saveDraft()
assert.equal(sent[0].channel_roles, 'left_service')
assert.equal(sent[0].version, 1)
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 7.2)
assert.equal(f.panel.reviewItems.value[0].selected, false)
await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
f.dispose()
})
test('preview retry uses preview readiness and rechecks readiness after confirmation', async () => {
const failed = task(1, { status: 'failed', preview_only: true, can_retry: true })
const f = setupPanel({ followupAudioDetail: async () => failed }, { capabilities: previewCaps() })
await settle(); await f.panel.openTask(1); await f.panel.retryTask()
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 1)
f.props.capabilities = previewCaps({ preview_ready: false, audio_verified: true })
await f.panel.retryTask()
assert.equal(f.calls.filter(call => call[0] === 'retry').length, 1)
f.dispose()
const confirm = deferred()
const g = setupPanel({ followupAudioDetail: async () => failed }, { capabilities: previewCaps() }, { confirm: () => confirm.promise })
await settle(); await g.panel.openTask(1)
const retry = g.panel.retryTask(); await settle(); g.props.capabilities.preview_ready = false; confirm.resolve(true); await retry
assert.equal(g.calls.filter(call => call[0] === 'retry').length, 0)
g.dispose()
})
test('preview task origin permanently blocks apply even after global mode becomes normal or can_apply is forged true', async () => {
for (const capabilities of [previewCaps({ can_apply: true, audio_verified: true }), { ...caps(), preview_only: false, can_review: true }]) {
const f = setupPanel({ followupAudioDetail: async () => task(1, { preview_only: true }) }, { capabilities })
await settle(); await f.panel.openTask(1)
assert.equal(f.panel.previewOnly.value, true)
assert.equal(f.panel.canEditReview.value, true)
assert.equal(f.panel.canApplyReview.value, false)
await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
assert.deepEqual(f.events, [])
f.dispose()
}
assert.equal(state.followupCanApply(caps(), true), false)
})
test('preview never emits an applied event from polling or a late normal-mode apply response', async () => {
let current = task(1, { preview_only: true })
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current }, { capabilities: previewCaps() })
await settle(); await f.panel.openTask(1)
current = { ...current, version: 2, status: 'applied' }; f.tick(); await settle()
assert.equal(f.panel.detail.value.status, 'applied')
assert.deepEqual(f.events, [])
f.dispose()
const response = deferred()
const g = setupPanel({ followupAudioApply: async payload => { g.calls.push(['apply', payload]); return response.promise } })
await settle(); await g.panel.openTask(1)
const applying = g.panel.applySelected(); await settle()
assert.equal(g.calls.filter(call => call[0] === 'apply').length, 1, 'request began in normal mode, not preview')
g.props.capabilities = previewCaps(); await settle(); response.resolve({ id: 1, status: 'applied', applied_items: [] }); await applying
assert.deepEqual(g.events, [])
assert.equal(g.notices.filter(([kind]) => kind === 'success').length, 0)
g.dispose()
})
test('outside preview allowlist panel remains hidden and makes no feature requests; revocation fences old responses', async () => {
for (const enabled of [false, true]) {
const f = setupPanel({}, { capabilities: previewCaps({ enabled, test_scope_allowed: false, models: [] }) })
await settle(); await f.panel.openTask(1); f.panel.openUpload(); await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.panel.featureEnabled.value, false)
assert.equal(f.panel.uploadVisible.value, false)
assert.deepEqual(f.calls, [])
assert.deepEqual(f.events, [])
f.dispose()
}
const pending = deferred()
const g = setupPanel({ followupAudioDetail: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); const opening = g.panel.openTask(1); await settle()
g.props.capabilities = previewCaps({ enabled: false, test_scope_allowed: false, models: [] }); await settle()
pending.resolve(task(1, { preview_only: true, transcript: '旧范围合成文本' })); await opening
assert.equal(g.panel.detail.value, null)
assert.equal(g.timers.size, 0)
assert.equal(g.panel.featureEnabled.value, false)
g.dispose()
assert.match(fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8'), / {
for (const props of [{ viewOnly: true, capabilities: previewCaps() }, { capabilities: previewCaps({ can_review: false, can_apply: true }) }]) {
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ preview_only: true }) }, props)
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft(); await f.panel.applySelected()
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.equal(f.calls.filter(call => ['save', 'apply'].includes(call[0])).length, 0)
f.dispose()
}
const pending = deferred()
const g = setupPanel({ followupAudioDetail: async () => stereoTask({ preview_only: true }), followupAudioSaveDraft: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('left_service')
const saving = g.panel.saveDraft(); await settle(); g.props.diagnosisId = 20; await settle()
pending.resolve(stereoTask({ preview_only: true, version: 2, channel_roles: 'left_service' })); await saving
assert.equal(g.panel.detail.value, null)
assert.equal(g.panel.channelRoles.value, 'unconfirmed')
assert.deepEqual(g.events, [])
assert.deepEqual(g.notices, [])
g.dispose()
})
test('actual patient editor outside preview allowlist still opens and edits its ordinary diagnosis form', async () => {
const edits = [], warnings = [], events = []
const filename = path.join(componentDir, '../edit.vue')
const descriptor = parse(fs.readFileSync(filename, 'utf8'), { filename }).descriptor
const script = compileScript(descriptor, { id: 'preview-editor-runtime' })
const module = execute(script.content, name => {
if (name === 'vue') return { ...vue, onBeforeUnmount() {} }
if (name === '@/api/tcm') return {
tcmDiagnosisEdit: async payload => { edits.push(payload); return {} },
tcmDiagnosisDetail: async ({ id }) => ({ id: String(id), patient_id: String(id + 100), patient_name: '合成患者', phone: '13800000000', id_card: '', remark: '普通原始备注', diabetes_discovery_year: '', show_card: 1 })
}
if (name === '@/api/app') return { getDictData: async () => ({}) }
if (name === '@/api/followupAudio') return { followupAudioCapabilities: async () => previewCaps({ enabled: false, test_scope_allowed: false, can_upload: false, can_review: false, models: [] }) }
if (name === '@/utils/feedback') return { default: { msgWarning: msg => warnings.push(msg) } }
if (name === '@/utils/perm') return { hasPermission: () => true }
if (name === '@/stores/modules/user') return { default: () => ({ userInfo: { id: 1 } }) }
if (name === '@/stores/modules/app') return { default: () => ({ getImageUrl: value => value }) }
if (name === 'element-plus') return { ElMessage: { warning() {} } }
if (name === '@element-plus/icons-vue' || name === '@/api/patient') return {}
if (name.endsWith('.vue')) return { default: {} }
throw new Error(`Unexpected editor import: ${name}`)
})
const scope = vue.effectScope()
try {
const editor = scope.run(() => module.default.setup({}, { expose() {}, emit: (...args) => events.push(args) }))
await editor.open('edit', 30); await settle()
assert.equal(editor.visible.value, true)
assert.equal(editor.formData.value.id, '30')
assert.equal(editor.followupCapabilities.value.enabled, false)
assert.equal(editor.followupCapabilities.value.test_scope_allowed, false)
assert.equal(editor.basicDirty.value, false)
editor.formData.value.remark = '合成普通表单编辑'
assert.equal(editor.basicDirty.value, true)
await editor.handleSubmit()
assert.equal(edits.length, 1, 'ordinary diagnosis mock API remains usable; no real patient write')
assert.equal(edits[0].remark, '合成普通表单编辑')
assert.match(descriptor.template.content, /v-if="followupCapabilities\?\.enabled && formData.id"/)
} finally { scope.stop() }
})
test('preview upload stops before create when readiness, upload permission or allowed scope is withdrawn', async () => {
for (const withdrawn of [{ preview_ready: false }, { can_upload: false }, { test_scope_allowed: false, enabled: false }]) {
const pending = deferred()
const f = setupPanel({ followupAudioUploadChunk: async () => pending.promise }, { capabilities: previewCaps() })
await settle(); f.panel.file.value = new File(['synthetic'], 'test.wav'); f.panel.recordedAt.value = '2026-09-29 09:00:00'
const uploading = f.panel.submitUpload(); await settle()
Object.assign(f.props.capabilities, withdrawn); await settle(); pending.resolve({ received: true }); await uploading
assert.equal(f.calls.filter(call => call[0] === 'complete' || call[0] === 'create').length, 0)
assert.equal(f.panel.uploading.value, false)
assert.deepEqual(f.events, [])
f.dispose()
}
})
test('dirty draft is replaced by the authoritative applied result from another operator', async () => {
let stored = task()
const copy = value => JSON.parse(JSON.stringify(value))
const f = setupPanel({ followupAudioLists: async () => ({ items: [copy(stored)] }), followupAudioDetail: async () => copy(stored) })
await settle(); await f.panel.openTask(1)
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 9.9
stored = task(1, { status: 'applied', version: 2, items: [item({ values: { fasting_blood_sugar: 6.5 }, selected: true, needs_review: false })] })
await f.panel.refresh(); await settle()
assert.equal(f.panel.detail.value.status, 'applied')
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 6.5)
assert.equal(f.panel.reviewItems.value[0].selected, true)
assert.equal(f.panel.reviewItems.value[0].needs_review, false)
assert.equal(f.panel.dirty.value, false)
assert.match(f.panel.errorMessage.value, /未保存.*未写入/)
assert.deepEqual(f.events, [['applied', 10]])
f.dispose()
})
test('manual human-reviewed business mode exposes upload and explicit Apply without claiming model accuracy', async () => {
const manual = { ...caps(), audio_verified: false, preview_only: false, manual_review_enabled: true, manual_review_ready: true, can_review: true }
assert.equal(state.followupAudioReady(manual), true)
assert.equal(state.followupCanApply(manual), true)
assert.equal(state.followupCanApply(manual, true), false)
assert.equal(state.followupAudioReady({ ...manual, manual_review_enabled: false }), false)
assert.equal(state.followupCanApply({ ...manual, manual_review_ready: false }), false)
assert.equal(state.followupAudioReady({ ...manual, manual_review_ready: undefined }), false)
const f = setupPanel({}, { capabilities: manual }); await settle(); await f.panel.openTask(1)
assert.equal(f.panel.canUpload.value, true)
assert.equal(f.panel.canApplyReview.value, true)
await f.panel.saveDraft(); assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
await f.panel.applySelected(); assert.equal(f.calls.filter(x => x[0] === 'apply').length, 1)
f.dispose()
})
test('dialogue windows retain both channels, empty replies and coarse times without mutating evidence', () => {
const segments = stereoSegments().reverse()
const before = JSON.stringify(segments)
const windows = state.dialogueWindows(segments)
assert.deepEqual(windows.map(row => [row.start_ms, row.end_ms, row.segments.map(segment => segment.channel)]), [[0, 15000, [0, 1]], [15000, 30000, [0, 1]]])
assert.equal(JSON.stringify(segments), before)
assert.equal(state.dialogueWindows([{ ...segments[0], text: '' }])[0].segments[0].text, '')
assert.equal(state.dialoguePlaybackStart(15000), 12000)
assert.equal(state.dialoguePlaybackStart(1000), 0)
assert.equal(state.dialoguePlaybackStart(NaN), 0)
assert.equal(state.channelPlaybackLabel(1, 'left_service'), '只听患者/家属')
assert.equal(state.channelPlaybackLabel(0, 'right_service'), '只听患者/家属')
assert.equal(state.channelPlaybackLabel(1, 'unconfirmed'), '只听右声道')
})
test('full dialogue evidence restores both channels after isolated playback and adds bounded context', async () => {
const audio = fakeAudioContext()
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'left_service' }) }, {}, { playback: audio.playback })
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
await f.panel.loadAudio(15000, 0)
assert.equal(f.panel.playbackChannel.value, 0)
await f.panel.loadDialogue(15000)
assert.equal(f.panel.playbackChannel.value, 'original')
assert.equal(audio.element.currentTime, 12)
assert.deepEqual(audio.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
assert.deepEqual(audio.nodes.find(node => node.name === 'splitter').edges, [])
assert.equal(audio.element.paused, false)
assert.equal(f.panel.conversationWindows.value.length, 2)
f.dispose()
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
assert.match(source, /@seek="loadDialogue"/)
assert.match(source, /@click="loadDialogue\(window.start_ms\)"/)
assert.doesNotMatch(source, /@click="loadAudio\(segment.start_ms, segment.channel\)"/)
})
test('upload sends explicitly selected pre-extraction roles and clears them on patient switch', async () => {
for (const roles of ['left_service', 'right_service']) {
const f = setupPanel()
await settle(); f.panel.openUpload()
f.panel.file.value = new File(['synthetic'], 'test.wav')
f.panel.recordedAt.value = '2026-09-29 09:00:00'
f.panel.uploadChannelRoles.value = roles
await f.panel.submitUpload()
assert.equal(f.calls.find(call => call[0] === 'create')[1].channel_roles, roles)
f.props.diagnosisId = 20; await settle()
assert.equal(f.panel.uploadChannelRoles.value, 'unconfirmed')
f.dispose()
}
})
test('a new recording never inherits the previous file role assignment', async () => {
const f = setupPanel(); await settle()
f.panel.uploadChannelRoles.value = 'left_service'
f.panel.chooseFile({ target: { files: [new File(['synthetic'], 'new.wav')] } })
assert.equal(f.panel.uploadChannelRoles.value, 'unconfirmed')
f.dispose()
})