feat: add scoped read-only audio preview and isolated Dify transport
This commit is contained in:
@@ -1206,3 +1206,220 @@ test('task switch and observed audio expiry both close live stereo routing and r
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user