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