feat(followup-audio): inline review with serialized draft autosave
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
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 } = require('@vue/compiler-sfc')
|
||||
const dir = path.resolve(__dirname, '../src/views/tcm/diagnosis/components')
|
||||
const reviewFile = process.env.FOLLOWUP_INLINE_REVIEW_SOURCE || path.join(dir, 'FollowupAudioReview.vue')
|
||||
function execute(source, requireFn) {
|
||||
const out = ts.transpileModule(source,{compilerOptions:{target:ts.ScriptTarget.ES2022,module:ts.ModuleKind.CommonJS}}).outputText
|
||||
const module={exports:{}};new Function('require','module','exports',out)(requireFn,module,module.exports);return module.exports
|
||||
}
|
||||
const state=execute(fs.readFileSync(path.join(dir,'followupAudioState.ts'),'utf8'),require)
|
||||
const row=(id,extra={})=>({id,kind:'blood',values:{fasting_blood_sugar:6.2},record_date:'2026-10-12',record_time:'08:00',time_period:'早晨',time_estimated:true,time_text:'早晨',date_text:'今天',evidence:[{text:'六点二',channel:1,segment_id:'right-1',start_ms:0,end_ms:15000,position_type:'segment'}],needs_review:true,selected:false,target_id:null,current_values:{},expected_hash:'original',conflict:false,possible_duplicate:false,...extra})
|
||||
function setup(extra={}){
|
||||
const descriptor=parse(fs.readFileSync(reviewFile,'utf8'),{filename:reviewFile}).descriptor
|
||||
const compiled=compileScript(descriptor,{id:'inline-review'})
|
||||
const mod=execute(compiled.content,name=>name==='vue'?vue:name==='./followupAudioState'?state:{default:{}})
|
||||
const props=vue.reactive({items:[row('one'),row('two'),row('three')],fields:{blood:[{key:'fasting_blood_sugar',label:'空腹血糖',type:'number'}]},disabled:false,canDaily:true,applied:false,saveState:'saved',...extra})
|
||||
const events=[];const scope=vue.effectScope();const review=scope.run(()=>mod.default.setup(props,{expose(){},emit(event,...args){events.push([event,...args]);if(event==='update:items')props.items=args[0]}}))
|
||||
return {review,props,events,dispose:()=>scope.stop()}
|
||||
}
|
||||
test('inline editor has one permanently mounted player slot and no modal overlay',()=>{
|
||||
const source=fs.readFileSync(reviewFile,'utf8');assert.doesNotMatch(source,/<el-dialog/);assert.equal((source.match(/<slot name="player"/g)||[]).length,1)
|
||||
assert.match(source,/position: sticky/);assert.match(source,/aria-label="同页核对与回听"/)
|
||||
const descriptor=parse(source,{filename:reviewFile}).descriptor;const script=compileScript(descriptor,{id:'inline-compile'})
|
||||
const render=compileTemplate({source:descriptor.template.content,filename:reviewFile,id:'inline-compile',compilerOptions:{bindingMetadata:script.bindings}});assert.deepEqual(render.errors,[])
|
||||
})
|
||||
test('accept-next requires explicit verification, retains immutable evidence and opens next unselected item',async()=>{
|
||||
const f=setup({items:[row('one'),row('two',{selected:true,needs_review:false}),row('three')]});f.review.openReview('one')
|
||||
f.review.saveReview(true,true);assert.equal(f.props.items[0].selected,false);assert.equal(f.review.editor.value.id,'one')
|
||||
f.review.confirmReview(true);f.review.saveReview(true,true)
|
||||
assert.equal(f.props.items[0].selected,true);assert.equal(f.props.items[0].needs_review,false);assert.equal(f.review.editor.value.id,'three')
|
||||
assert.deepEqual(f.props.items[0].evidence,row('one').evidence);assert.ok(f.events.some(e=>e[0]==='saved-next'))
|
||||
await vue.nextTick();assert.equal(f.review.editor.value.id,'three');f.dispose()
|
||||
})
|
||||
test('pending-filter accept-next captures next id before accepted row leaves filter',()=>{
|
||||
const f=setup();f.review.group.value='pending';f.review.openReview('one');f.review.confirmReview(true);f.review.saveReview(true,true)
|
||||
assert.equal(f.review.editor.value.id,'two');assert.deepEqual(f.review.visibleItems.value.map(r=>r.id),['two','three']);f.dispose()
|
||||
})
|
||||
test('skip preserves patient values, does not claim review complete, and never writes or selects',()=>{
|
||||
const f=setup();f.review.openReview('one');f.review.skipAndNext();assert.equal(f.review.editor.value.id,'two');assert.equal(f.props.items[0].needs_review,true);assert.equal(f.props.items[0].selected,false)
|
||||
assert.deepEqual(f.props.items[0].values,{fasting_blood_sugar:6.2});assert.ok(f.events.every(e=>['update:items','saved-next'].includes(e[0])));f.dispose()
|
||||
})
|
||||
test('inline row switching or skipping never silently drops unsaved edits',()=>{
|
||||
const f=setup();f.review.openReview('one');f.review.setValue('fasting_blood_sugar',6.8);f.review.openReview('two');assert.equal(f.review.editor.value.id,'one');assert.match(f.review.editorNotice.value,/未保存/)
|
||||
f.review.skipAndNext();assert.equal(f.review.editor.value.id,'one');assert.equal(f.review.editor.value.values.fasting_blood_sugar,6.8);assert.equal(f.props.items[0].values.fasting_blood_sugar,6.2);f.dispose()
|
||||
})
|
||||
test('date change remains gated until saved and authoritative target refreshed',()=>{
|
||||
const f=setup();f.review.openReview('one');f.review.patchEditor({record_date:'2026-10-11'});f.review.confirmReview(true);f.review.saveReview(true,true);assert.equal(f.props.items[0].selected,false);assert.equal(f.review.editor.value.id,'one')
|
||||
f.review.saveReview(false);assert.equal(f.props.items[0].record_date,'2026-10-11');assert.equal(f.props.items[0].needs_review,true);assert.ok(f.events.some(e=>e[0]==='saved-next'));f.dispose()
|
||||
})
|
||||
test('last item completes without wrapping or applying, read-only cannot advance mutations',()=>{
|
||||
const f=setup({items:[row('one')]});f.review.openReview('one');f.review.confirmReview(true);f.review.saveReview(true,true);assert.equal(f.review.editor.value,null);assert.match(f.review.navigationNotice.value,/末项/);assert.equal(f.events.some(e=>e[0]==='apply'),false);f.dispose()
|
||||
const g=setup({disabled:true});g.review.openReview('one');g.review.skipAndNext();g.review.saveReview(true,true);assert.equal(g.events.length,0);g.dispose()
|
||||
})
|
||||
|
||||
test('reopening the same row or toggling its selection preserves unsaved editor data',()=>{
|
||||
const f=setup();f.review.openReview('one');f.review.setValue('fasting_blood_sugar',7.1)
|
||||
f.review.openReview('one');assert.equal(f.review.editor.value.values.fasting_blood_sugar,7.1)
|
||||
f.review.toggleSelection(f.props.items[0],true);assert.equal(f.review.editor.value.values.fasting_blood_sugar,7.1)
|
||||
assert.equal(f.props.items[0].values.fasting_blood_sugar,6.2);assert.equal(f.props.items[0].selected,false);f.dispose()
|
||||
})
|
||||
@@ -369,7 +369,7 @@ test('changed real-row target and changed record date require saving a fresh sna
|
||||
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, /先保存审核草稿/)
|
||||
assert.match(f.panel.applyIssue.value, /审核草稿尚未保存完成/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
@@ -387,6 +387,8 @@ test('read-only identity fields cannot be selected or applied, including mixed e
|
||||
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.match(f.panel.applyIssue.value, /尚未保存完成/)
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(f.panel.applyIssue.value, '')
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioReview.vue'), 'utf8')
|
||||
@@ -576,7 +578,7 @@ test('server refresh during a modal edit keeps local content, uses fresh evidenc
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('panel leave guard includes unsaved modal edits; recording original is collapsed until playback', async () => {
|
||||
test('panel leave guard includes unsaved editor edits; playback stays separate from collapsed transcript', 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 })
|
||||
@@ -587,7 +589,7 @@ test('panel leave guard includes unsaved modal edits; recording original is coll
|
||||
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'])
|
||||
assert.deepEqual(f.panel.detailSections.value, [], 'same-page player does not expand the transcript')
|
||||
await f.panel.backToList()
|
||||
assert.deepEqual(f.panel.detailSections.value, [])
|
||||
r.dispose(); f.dispose()
|
||||
@@ -1151,7 +1153,7 @@ test('API forwards optional channel_roles only in draft and stereo apply uses se
|
||||
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 })) }
|
||||
current = { ...current, version: payload.version + 1, channel_roles: payload.channel_roles || current.channel_roles, items: payload.channel_roles ? payload.items.map(row => ({ ...row, selected: false, needs_review: true })) : payload.items }
|
||||
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()
|
||||
@@ -1159,9 +1161,10 @@ test('API forwards optional channel_roles only in draft and stereo apply uses se
|
||||
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.saveDraft()
|
||||
await f.panel.applySelected()
|
||||
assert.equal(applied.length, 1)
|
||||
assert.equal(applied[0].version, 2)
|
||||
assert.equal(applied[0].version, 3)
|
||||
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]])
|
||||
@@ -1553,3 +1556,240 @@ test('cancelled file selection keeps current file and roles; new upload starts u
|
||||
assert.equal(f.panel.uploadChannelRoles.value, 'unconfirmed')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave ignores hydration, debounces committed draft edits and never writes clinical records', async () => {
|
||||
const f = setupPanel({}, { capabilities: previewCaps() })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0, 'server hydration does not save itself')
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.1
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7.2
|
||||
f.panel.reviewItems.value[0].selected = false
|
||||
assert.equal(f.panel.saveState.value, 'pending')
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0)
|
||||
f.tick(); await settle()
|
||||
const saves = f.calls.filter(x => x[0] === 'save')
|
||||
assert.equal(saves.length, 1)
|
||||
assert.equal(saves[0][1].items[0].values.fasting_blood_sugar, 7.2)
|
||||
assert.equal(saves[0][1].items[0].selected, false)
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
assert.equal(f.panel.persistedRevision.value, 1)
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 1, 'ACK hydration causes no write loop')
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave serializes delayed commits, merges newer local edits and advances request versions', async () => {
|
||||
const requests = [], pending = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { requests.push(payload); const next = deferred(); pending.push(next); return next.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
f.tick(); await settle()
|
||||
assert.equal(requests.length, 1)
|
||||
assert.equal(f.panel.saveState.value, 'saving')
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
f.panel.reviewItems.value[0].selected = false
|
||||
f.tick(); await settle()
|
||||
assert.equal(requests.length, 1, 'only one request in flight')
|
||||
pending[0].resolve(task(1, { version: 2, items: requests[0].items })); await settle()
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
assert.equal(f.panel.saveState.value, 'pending')
|
||||
assert.equal(f.panel.persistedRevision.value, 0, 'outdated ACK does not claim latest edits saved')
|
||||
f.tick(); await settle()
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(requests[1].version, 2)
|
||||
assert.equal(requests[1].items[0].values.fasting_blood_sugar, 8)
|
||||
pending[1].resolve(task(1, { version: 3, items: requests[1].items })); await settle()
|
||||
assert.equal(f.panel.detail.value.version, 3)
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
assert.equal(f.panel.committedDirty.value, false)
|
||||
assert.equal(f.panel.persistedRevision.value, 1)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('same-data save ACK retains item references and never disables or saves a dirty next editor', async () => {
|
||||
const pending = deferred(), sent = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { sent.push(payload); return pending.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
f.tick(); await settle()
|
||||
const committed = f.panel.reviewItems.value
|
||||
f.panel.reviewRef.value = { hasUnsavedChanges: () => true }
|
||||
pending.resolve(task(1, { version: 2, items: sent[0].items })); await settle()
|
||||
assert.equal(f.panel.reviewItems.value, committed, 'ACK must not trigger Review items replacement')
|
||||
assert.equal(f.panel.dirty.value, true, 'uncommitted next editor remains dirty')
|
||||
assert.equal(f.panel.committedDirty.value, false)
|
||||
assert.equal(sent.length, 1)
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /:disabled="!canEditReview \|\| uploading \|\| \(mutation !== '' && mutation !== 'save'\)"/)
|
||||
})
|
||||
|
||||
test('autosave writes committed items even while a different editor has uncommitted changes', async () => {
|
||||
const f = setupPanel(); await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].selected = false
|
||||
f.panel.reviewRef.value = { hasUnsavedChanges: () => true }
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 1)
|
||||
assert.equal(f.panel.dirty.value, true)
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave failure stops retries and Apply until an explicit retry succeeds', async () => {
|
||||
let failing = true
|
||||
const f = setupPanel({ followupAudioSaveDraft: async payload => {
|
||||
f.calls.push(['save', payload])
|
||||
if (failing) throw new Error('网络中断')
|
||||
return task(1, { version: payload.version + 1, items: payload.items })
|
||||
} })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.panel.saveState.value, 'error')
|
||||
assert.match(f.panel.errorMessage.value, /自动保存已暂停.*重试保存/)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
f.tick(); await settle(); f.tick(); await settle()
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 1)
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
failing = false; await f.panel.saveDraft()
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 2)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave conflict refreshes immutable metadata, keeps local values and pauses instead of looping', async () => {
|
||||
let stale = false
|
||||
const f = setupPanel({
|
||||
followupAudioDetail: async () => task(1, { version: stale ? 2 : 1, items: [item({ expected_hash: stale ? 'new' : 'old' })] }),
|
||||
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 = 8
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.panel.saveState.value, 'conflict')
|
||||
assert.equal(f.panel.reviewItems.value[0].expected_hash, 'new')
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 9
|
||||
f.tick(); await settle(); f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 1)
|
||||
assert.equal(f.panel.saveState.value, 'conflict')
|
||||
assert.match(f.panel.errorMessage.value, /重新核对.*重试保存/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('autosave Apply stays explicit and blocked while pending, saving or edited during confirmation', async () => {
|
||||
const pending = deferred(), confirmation = deferred(), sent = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { sent.push(payload); return pending.promise } }, {}, { confirm: () => confirmation.promise })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
f.tick(); await settle(); await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
pending.resolve(task(1, { version: 2, items: sent[0].items })); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0, 'draft ACK never auto-applies')
|
||||
const applying = f.panel.applySelected(); await settle()
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
confirmation.resolve(true); await applying
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0, 'confirmation does not authorize edits made after it opened')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('pending autosave and late ACK cannot cross patients or tasks', async () => {
|
||||
const pending = deferred(), sent = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { sent.push(payload); return pending.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 7
|
||||
f.props.diagnosisId = 20; await settle(); f.tick(); await settle()
|
||||
assert.equal(sent.length, 0, 'debounced old-patient draft is cancelled')
|
||||
await f.panel.openTask(2)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
f.tick(); await settle()
|
||||
assert.equal(sent.length, 1)
|
||||
assert.equal(sent[0].id, 2)
|
||||
f.props.diagnosisId = 30; await settle(); await f.panel.openTask(3)
|
||||
pending.resolve(task(2, { version: 2, items: sent[0].items })); await settle()
|
||||
assert.equal(f.panel.selectedId.value, 3)
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 6.5)
|
||||
assert.equal(f.panel.persistedRevision.value, 0)
|
||||
assert.equal(f.panel.saveState.value, 'idle')
|
||||
assert.deepEqual(f.events, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('role autosave preserves a newer mapping and serializes another save without selecting suggestions', async () => {
|
||||
const requests = [], pending = []
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: payload => { requests.push(payload); const request = deferred(); pending.push(request); return request.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.setChannelRoles('left_service'); f.tick(); await settle()
|
||||
f.panel.setChannelRoles('right_service')
|
||||
assert.equal(f.panel.channelRoles.value, 'right_service', 'role edits stay available during save')
|
||||
pending[0].resolve(stereoTask({ version: 2, channel_roles: 'left_service', items: [item({ selected: false, needs_review: true })] })); await settle()
|
||||
assert.equal(f.panel.channelRoles.value, 'right_service')
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
f.tick(); await settle()
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(requests[1].version, 2)
|
||||
assert.equal(requests[1].channel_roles, 'right_service')
|
||||
pending[1].resolve(stereoTask({ version: 3, channel_roles: 'right_service', items: requests[1].items })); await settle()
|
||||
assert.equal(f.panel.saveState.value, 'saved')
|
||||
assert.equal(f.panel.roleDirty.value, false)
|
||||
assert.equal(f.calls.filter(x => x[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('panel has one same-page slotted player and collapses only transcript and advanced settings', () => {
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.equal((source.match(/<audio\s/g) || []).length, 1)
|
||||
assert.match(source, /<template #player>[\s\S]*?<audio[\s\S]*?<\/template>\s*<\/FollowupAudioReview>/)
|
||||
assert.doesNotMatch(source.slice(source.indexOf('<el-collapse v-model="detailSections"'), source.indexOf('<div v-if="detail.status === \'review\'" class="review-actions"')), /<audio\s/)
|
||||
assert.match(source, /el-collapse-item title="高级设置 · 分析模型"/)
|
||||
assert.match(source, /!editingRoles && !roleDirty/)
|
||||
assert.match(source, /@click="editingRoles = true">修改/)
|
||||
})
|
||||
|
||||
test('late save merging never resurrects selections invalidated by the server snapshot', async () => {
|
||||
const pending = deferred(), sent = []
|
||||
const f = setupPanel({ followupAudioSaveDraft: payload => { sent.push(payload); return pending.promise } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.reviewItems.value[0].target_id = 9
|
||||
f.panel.reviewItems.value[0].selected = false
|
||||
f.tick(); await settle()
|
||||
f.panel.reviewItems.value[0].selected = true
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
pending.resolve(task(1, { version: 2, items: sent[0].items.map(row => ({ ...row, expected_hash: 'new-target', selected: false, needs_review: true })) })); await settle()
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
assert.equal(f.panel.reviewItems.value[0].expected_hash, 'new-target')
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('detail fetch begun before an edit preserves that edit when the new snapshot arrives', async () => {
|
||||
const pending = deferred()
|
||||
let fetching = false
|
||||
const f = setupPanel({ followupAudioDetail: async () => fetching ? pending.promise : task() })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
fetching = true
|
||||
const refreshing = f.panel.readDetail(1)
|
||||
f.panel.reviewItems.value[0].values.fasting_blood_sugar = 8
|
||||
pending.resolve(task(1, { version: 2, items: [item({ expected_hash: 'fresh' })] })); await refreshing
|
||||
assert.equal(f.panel.reviewItems.value[0].values.fasting_blood_sugar, 8)
|
||||
assert.equal(f.panel.reviewItems.value[0].expected_hash, 'fresh')
|
||||
assert.equal(f.panel.saveState.value, 'conflict')
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.calls.filter(x => x[0] === 'save').length, 0)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user