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

656 lines
41 KiB
JavaScript

const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const test = require('node:test')
const ts = require('typescript')
const vue = require('vue')
const { parse, compileScript, compileTemplate, compileStyle } = require('@vue/compiler-sfc')
const 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 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 === './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; \}/)
})