feat(followup-audio): soft delete unadopted recordings and expose extraction doubts

This commit is contained in:
2026-10-12 10:39:12 +08:00
parent 70ddc1a70a
commit 14e1722291
13 changed files with 426 additions and 18 deletions
+55
View File
@@ -38,6 +38,7 @@ function setupPanel(overrides = {}, propsPatch = {}, options = {}) {
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' } },
followupAudioDelete: async payload => { calls.push(['delete', payload]); return { id: payload.id, status: 'deleted', version: payload.version + 1 } },
followupAudioRetry: async id => { calls.push(['retry', id]); return { id, status: 'queued' } },
...overrides
}
@@ -1793,3 +1794,57 @@ test('detail fetch begun before an edit preserves that edit when the new snapsho
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0)
f.dispose()
})
test('delete requires actor-aware capability and blocks applied/running/unknown tasks', async () => {
const f=setupPanel();await settle()
for (const row of [task(), task(1,{can_delete:false}),task(1,{can_delete:true,status:'applied'}),task(1,{can_delete:true,status:'running'}),task(1,{can_delete:true,status:'needs_reconciliation'})]) await f.panel.deleteRecording(row)
assert.equal(f.calls.filter(c=>c[0]==='delete').length,0)
f.props.viewOnly=true;await f.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(f.calls.filter(c=>c[0]==='delete').length,0);f.dispose()
})
test('confirmed deletion sends optimistic version once, clears selected editor and never applies', async () => {
let removed=false;const current=task(1,{can_delete:true})
const f=setupPanel({followupAudioDetail:async()=>current,followupAudioLists:async()=>({items:removed?[]:[current]}),followupAudioDelete:async p=>{f.calls.push(['delete',p]);removed=true;return {id:p.id,status:'deleted',version:p.version+1}}})
await settle();await f.panel.openTask(1);await f.panel.deleteRecording(current)
assert.deepEqual(f.calls.find(c=>c[0]==='delete')[1],{id:1,version:1});assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.detail.value,null);assert.deepEqual(f.panel.tasks.value,[]);assert.deepEqual(f.panel.reviewItems.value,[])
assert.equal(f.calls.some(c=>c[0]==='apply'),false);assert.equal(f.panel.mutation.value,'');f.dispose()
})
test('delete cancellation keeps recording and drafts, patient switch before confirm sends nothing', async () => {
const f=setupPanel({}, {}, {confirm:async()=>{throw new Error('cancel')}});await settle();await f.panel.openTask(1);await f.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(f.panel.selectedId.value,1);assert.equal(f.calls.some(c=>c[0]==='delete'),false);assert.equal(f.panel.mutation.value,'');f.dispose()
const answer=deferred();const g=setupPanel({}, {}, {confirm:()=>answer.promise});await settle();const pending=g.panel.deleteRecording(task(1,{can_delete:true}));g.props.diagnosisId=20;await settle();answer.resolve();await pending;assert.equal(g.calls.some(c=>c[0]==='delete'),false);g.dispose()
})
test('late delete result cannot clear another patient and errors preserve current recording', async () => {
const response=deferred();const f=setupPanel({followupAudioDelete:async p=>{f.calls.push(['delete',p]);return response.promise}});await settle();await f.panel.openTask(1)
const deleting=f.panel.deleteRecording(task(1,{can_delete:true}));await settle();const duplicate=f.panel.deleteRecording(task(1,{can_delete:true}));await duplicate;assert.equal(f.calls.filter(c=>c[0]==='delete').length,1)
f.props.diagnosisId=20;await settle();await f.panel.openTask(2);response.resolve({id:1,status:'deleted',version:2});await deleting;assert.equal(f.panel.selectedId.value,2);assert.equal(f.panel.detail.value.id,2);f.dispose()
const g=setupPanel({followupAudioDelete:async()=>{throw new apiModule.FollowupAudioError('版本已变化')}});await settle();await g.panel.openTask(1);await g.panel.deleteRecording(task(1,{can_delete:true}));assert.equal(g.panel.selectedId.value,1);assert.match(g.panel.errorMessage.value,/版本已变化/);g.dispose()
})
test('delete API has no automatic retry and empty results expose uncertainty reasons without tab switching', 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:{id:1,status:'deleted',version:2}}}}}))
await api.followupAudioDelete({id:1,version:1});assert.equal(requests[0].config.url,'/tcm.followupAudio/delete');assert.equal(requests[0].options.isOpenRetry,false)
const source=fs.readFileSync(path.join(componentDir,'FollowupAudioReview.vue'),'utf8');assert.match(source,/\(group === 'pending' \|\| !items.length\) && uncertainties\?\.length/)
const panelSource=fs.readFileSync(path.join(componentDir,'FollowupAudioPanel.vue'),'utf8');assert.match(panelSource,/未生成可写入信息/);assert.match(panelSource,/不删除或回滚病例/)
})
test('remote deletion revalidates missing detail and releases cached audio/editor', async () => {
let deleted=false;const f=setupPanel({followupAudioLists:async()=>({items:deleted?[]:[task()]}),followupAudioDetail:async id=>{if(deleted)throw new apiModule.FollowupAudioError('FOLLOWUP_AUDIO_TASK_UNAVAILABLE');return task(id)}})
await settle();await f.panel.openTask(1);await f.panel.loadAudio();assert.equal(f.panel.audioUrl.value,'blob:private-0')
deleted=true;await f.panel.refresh();await settle()
assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.detail.value,null);assert.equal(f.panel.audioUrl.value,'');assert.deepEqual(f.revoked,['blob:private-0']);assert.deepEqual(f.panel.reviewItems.value,[]);assert.match(f.panel.errorMessage.value,/录音已删除/);f.dispose()
})
test('list pagination omission alone never discards current unsaved edits', async () => {
let omit=false;const f=setupPanel({followupAudioLists:async()=>({items:omit?[]:[task()]})});await settle();await f.panel.openTask(1)
f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9;omit=true;await f.panel.refresh()
assert.equal(f.panel.selectedId.value,1);assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar,6.9);assert.notEqual(f.panel.saveState.value,'conflict');f.dispose()
})
test('deleting another row resumes current task autosave without clearing its draft', async () => {
const f=setupPanel();await settle();await f.panel.openTask(1);f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9
await f.panel.deleteRecording(task(2,{can_delete:true}));assert.equal(f.panel.selectedId.value,1);assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar,6.9)
f.tick();await settle();assert.ok(f.calls.some(c=>c[0]==='save'&&c[1].id===1&&c[1].items[0].values.fasting_blood_sugar===6.9));f.dispose()
})
test('autosave denial for remotely deleted task clears local playback without another save', async () => {
let deleted=false;const f=setupPanel({followupAudioLists:async()=>({items:deleted?[]:[task()]}),followupAudioSaveDraft:async()=>{deleted=true;throw new apiModule.FollowupAudioError('FOLLOWUP_AUDIO_TASK_UNAVAILABLE')}})
await settle();await f.panel.openTask(1);await f.panel.loadAudio();f.panel.reviewItems.value[0].values.fasting_blood_sugar=6.9;await f.panel.saveDraft(true);await settle()
assert.equal(f.panel.selectedId.value,0);assert.equal(f.panel.audioUrl.value,'');assert.match(f.panel.errorMessage.value,/录音已删除/);f.dispose()
})