1426 lines
92 KiB
JavaScript
1426 lines
92 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 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()
|
||
}
|
||
})
|