Files
zyt/admin/tests/followup-audio.test.cjs
T

1426 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' })
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, /<input|<textarea|<el-select|disabled/)
})
test('saving and reopening a local target change still compares against the server snapshot', () => {
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, `<?php\n${script}`)
result = require('node:child_process').spawnSync(process.env.PHP_BINARY || 'php', [filename, serviceDir], {
encoding: 'utf8', input: JSON.stringify({ stored: [original], submitted, source: [original] })
})
} finally { fs.rmSync(temp, { recursive: true, force: true }) }
assert.equal(result.status, 0, result.stderr || String(result.error || 'PHP merge process failed'))
assert.equal(result.stderr, '')
const merged = JSON.parse(result.stdout)
assert.deepEqual(merged.merged_values, { fasting_blood_sugar: 7 })
assert.deepEqual(merged.evidence, original.evidence)
assert.equal(merged.forged_error, 'FOLLOWUP_AUDIO_UNPROPOSED_FIELD')
f.dispose()
})
test('summary is explicitly an unverified AI draft even after selected rows have been applied', () => {
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
assert.match(source, /<div class="summary-box">\s*<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情<\/div>/)
})
const stereoSegments = () => [
{ id: 'left-0', channel: 0, start_ms: 0, end_ms: 15000, text: '合成左声道文本' },
{ id: 'right-0', channel: 1, start_ms: 0, end_ms: 15000, text: '合成右声道文本' },
{ id: 'left-1', channel: 0, start_ms: 15000, end_ms: 30000, text: '合成左侧后续' },
{ id: 'right-1', channel: 1, start_ms: 15000, end_ms: 30000, text: '合成右侧后续' }
]
const stereoTask = (extra = {}) => task(1, { transcript_segments: stereoSegments(), channel_roles: 'unconfirmed', ...extra })
function fakeAudioContext(options = {}) {
const nodes = [], events = []
const node = name => {
const result = { name, edges: [], connect(target, output = 0, input = 0) { this.edges.push({ target, output, input }); events.push(['connect', name, target.name, output, input]) }, disconnect() { this.edges = []; events.push(['disconnect', name]) } }
nodes.push(result); return result
}
const context = {
state: 'suspended', destination: node('destination'),
createMediaElementSource(element) { events.push(['media', element]); return node('media') },
createChannelSplitter(count) { events.push(['splitter', count]); return node('splitter') },
createChannelMerger(count) { events.push(['merger', count]); return node('merger') },
async resume() { events.push(['resume']); if (options.resume) await options.resume(); context.state = 'running' },
async close() { events.push(['close']); context.state = 'closed' }
}
const element = { currentTime: 0, paused: true, pause() { this.paused = true; events.push(['pause']) }, async play() { this.paused = false; events.push(['play']) } }
return { context, element, nodes, events, playback: { createFollowupAudioPlayback: element => playbackModule.createFollowupAudioPlayback(element, () => context) } }
}
test('stereo transcript order is window/channel, roles are unconfirmed until explicitly saved, legacy stays plain', async () => {
const response = stereoTask({ transcript_segments: [stereoSegments()[3], stereoSegments()[1], stereoSegments()[2], stereoSegments()[0]] })
const f = setupPanel({ followupAudioDetail: async () => response })
await settle(); await f.panel.openTask(1)
assert.deepEqual(f.panel.transcriptSegments.value.map(segment => segment.id), ['left-0', 'right-0', 'left-1', 'right-1'])
assert.equal(response.transcript_segments[0].id, 'right-1', 'display sorting never mutates server evidence')
assert.equal(f.panel.hasStereo.value, true)
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
assert.match(state.followupChannelLabel(0), /左声道 · 角色待确认/)
assert.match(state.followupChannelLabel(1, 'left_service'), /右声道 · 患者\/家属端(人工标注)/)
assert.match(state.followupChannelLabel(1, 'right_service'), /右声道 · 客服端(人工标注)/)
assert.match(state.followupChannelLabel(0, 'invalid'), /角色待确认/)
assert.equal(state.followupChannelLabel(undefined, 'left_service'), '未标注声道')
assert.equal(state.hasStereoTranscript([{ ...stereoSegments()[0], channel: undefined }]), false)
assert.equal(state.hasStereoTranscript([stereoSegments()[0]]), false)
assert.match(f.panel.applyIssue.value, /确认声道角色/)
await f.panel.applySelected()
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
f.dispose()
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
assert.match(source, /同一区间两侧可能同时发言,不表示发言先后/)
assert.match(source, /<pre v-else class="transcript-text">/)
})
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 is immutable through editor/draft and seek emits channel without guessing a role', 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, 1]])
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="仅测试识别与提炼,不写入病例"/)
assert.match(source, /<el-button v-if="canApplyReview"[^>]+@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'), /<section v-if="featureEnabled"/)
})
test('preview readonly and can_review denial cannot save roles or drafts, while old patient saves stay fenced', async () => {
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()
}
})