feat: preserve stereo channels and require reviewed speaker roles
This commit is contained in:
@@ -15,6 +15,7 @@ function execute(source, dependencies, globals = {}) {
|
||||
}
|
||||
function loadTs(filename, dependencies = require) { return execute(fs.readFileSync(filename, 'utf8'), dependencies) }
|
||||
const state = loadTs(path.join(componentDir, 'followupAudioState.ts'))
|
||||
const playbackModule = loadTs(path.join(componentDir, 'followupAudioPlayback.ts'))
|
||||
const apiModule = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: {} }))
|
||||
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||
const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve() }
|
||||
@@ -48,6 +49,7 @@ function setupPanel(overrides = {}, propsPatch = {}, options = {}) {
|
||||
if (name === 'element-plus') return { ElMessage: Object.fromEntries(['success', 'warning', 'error'].map(kind => [kind, msg => notices.push([kind, msg])])), ElMessageBox: { confirm: options.confirm || (async () => true) } }
|
||||
if (name === '@/api/followupAudio') return deps
|
||||
if (name === './followupAudioState') return state
|
||||
if (name === './followupAudioPlayback') return options.playback || playbackModule
|
||||
if (name === './FollowupAudioReview.vue') return { default: {} }
|
||||
throw new Error(`Unexpected import: ${name}`)
|
||||
}, {
|
||||
@@ -866,3 +868,341 @@ test('summary is explicitly an unverified AI draft even after selected rows have
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /<div class="summary-box">\s*<div class="muted">回访摘要 · AI未核实草稿,不代表已确认病情<\/div>/)
|
||||
})
|
||||
|
||||
const stereoSegments = () => [
|
||||
{ id: 'left-0', channel: 0, start_ms: 0, end_ms: 15000, text: '合成左声道文本' },
|
||||
{ id: 'right-0', channel: 1, start_ms: 0, end_ms: 15000, text: '合成右声道文本' },
|
||||
{ id: 'left-1', channel: 0, start_ms: 15000, end_ms: 30000, text: '合成左侧后续' },
|
||||
{ id: 'right-1', channel: 1, start_ms: 15000, end_ms: 30000, text: '合成右侧后续' }
|
||||
]
|
||||
const stereoTask = (extra = {}) => task(1, { transcript_segments: stereoSegments(), channel_roles: 'unconfirmed', ...extra })
|
||||
function fakeAudioContext(options = {}) {
|
||||
const nodes = [], events = []
|
||||
const node = name => {
|
||||
const result = { name, edges: [], connect(target, output = 0, input = 0) { this.edges.push({ target, output, input }); events.push(['connect', name, target.name, output, input]) }, disconnect() { this.edges = []; events.push(['disconnect', name]) } }
|
||||
nodes.push(result); return result
|
||||
}
|
||||
const context = {
|
||||
state: 'suspended', destination: node('destination'),
|
||||
createMediaElementSource(element) { events.push(['media', element]); return node('media') },
|
||||
createChannelSplitter(count) { events.push(['splitter', count]); return node('splitter') },
|
||||
createChannelMerger(count) { events.push(['merger', count]); return node('merger') },
|
||||
async resume() { events.push(['resume']); if (options.resume) await options.resume(); context.state = 'running' },
|
||||
async close() { events.push(['close']); context.state = 'closed' }
|
||||
}
|
||||
const element = { currentTime: 0, paused: true, pause() { this.paused = true; events.push(['pause']) }, async play() { this.paused = false; events.push(['play']) } }
|
||||
return { context, element, nodes, events, playback: { createFollowupAudioPlayback: element => playbackModule.createFollowupAudioPlayback(element, () => context) } }
|
||||
}
|
||||
|
||||
test('stereo transcript order is window/channel, roles are unconfirmed until explicitly saved, legacy stays plain', async () => {
|
||||
const response = stereoTask({ transcript_segments: [stereoSegments()[3], stereoSegments()[1], stereoSegments()[2], stereoSegments()[0]] })
|
||||
const f = setupPanel({ followupAudioDetail: async () => response })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
assert.deepEqual(f.panel.transcriptSegments.value.map(segment => segment.id), ['left-0', 'right-0', 'left-1', 'right-1'])
|
||||
assert.equal(response.transcript_segments[0].id, 'right-1', 'display sorting never mutates server evidence')
|
||||
assert.equal(f.panel.hasStereo.value, true)
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.match(state.followupChannelLabel(0), /左声道 · 角色待确认/)
|
||||
assert.match(state.followupChannelLabel(1, 'left_service'), /右声道 · 患者\/家属端(人工标注)/)
|
||||
assert.match(state.followupChannelLabel(1, 'right_service'), /右声道 · 客服端(人工标注)/)
|
||||
assert.match(state.followupChannelLabel(0, 'invalid'), /角色待确认/)
|
||||
assert.equal(state.followupChannelLabel(undefined, 'left_service'), '未标注声道')
|
||||
assert.equal(state.hasStereoTranscript([{ ...stereoSegments()[0], channel: undefined }]), false)
|
||||
assert.equal(state.hasStereoTranscript([stereoSegments()[0]]), false)
|
||||
assert.match(f.panel.applyIssue.value, /确认声道角色/)
|
||||
await f.panel.applySelected()
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
const source = fs.readFileSync(path.join(componentDir, 'FollowupAudioPanel.vue'), 'utf8')
|
||||
assert.match(source, /同一区间两侧可能同时发言,不表示发言先后/)
|
||||
assert.match(source, /<pre v-else class="transcript-text">/)
|
||||
})
|
||||
|
||||
test('stereo role draft is dirty, blocks apply and leave, saves exact role/version then requires item re-review', async () => {
|
||||
let current = stereoTask()
|
||||
const saves = []
|
||||
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
|
||||
saves.push(payload)
|
||||
current = { ...current, version: payload.version + 1, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
|
||||
return current
|
||||
} }, {}, { confirm: async () => { throw new Error('cancel') } })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
f.panel.setChannelRoles('left_service')
|
||||
assert.equal(f.panel.roleDirty.value, true)
|
||||
assert.equal(f.panel.dirty.value, true)
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'unconfirmed', 'unsaved role never labels a transcript as confirmed')
|
||||
assert.equal(await f.panel.confirmLeave(), false)
|
||||
await f.panel.applySelected(); assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(saves.length, 1)
|
||||
assert.equal(saves[0].channel_roles, 'left_service')
|
||||
assert.equal(saves[0].version, 1)
|
||||
assert.equal(f.panel.roleDirty.value, false)
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
|
||||
assert.equal(f.panel.selectedCount.value, 0)
|
||||
assert.equal(f.panel.reviewItems.value[0].needs_review, true)
|
||||
assert.match(f.notices.at(-1)[1], /不会自动重新提炼/)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('poll and stale save preserve unsaved role; refreshed save installs authoritative role and clears selections', async () => {
|
||||
let current = stereoTask()
|
||||
const f = setupPanel({ followupAudioLists: async () => ({ items: [current] }), followupAudioDetail: async () => current,
|
||||
followupAudioSaveDraft: async () => { throw new apiModule.FollowupAudioError('版本已变化', 'FOLLOWUP_AUDIO_STALE_REVIEW') } })
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('right_service')
|
||||
current = { ...current, version: 2, channel_roles: 'left_service' }
|
||||
f.tick(); await settle()
|
||||
assert.equal(f.panel.channelRoles.value, 'right_service')
|
||||
assert.equal(f.panel.savedChannelRoles.value, 'left_service')
|
||||
assert.equal(f.panel.roleDirty.value, true)
|
||||
assert.equal(f.panel.reviewItems.value[0].selected, false)
|
||||
await f.panel.saveDraft()
|
||||
assert.equal(f.panel.channelRoles.value, 'right_service')
|
||||
assert.equal(f.panel.roleDirty.value, true)
|
||||
f.dispose()
|
||||
const g = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: async payload => stereoTask({ version: 2, channel_roles: payload.channel_roles, review_refreshed: true, items: [item({ selected: false, needs_review: true })] }) })
|
||||
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('right_service'); await g.panel.saveDraft()
|
||||
assert.equal(g.panel.channelRoles.value, 'right_service')
|
||||
assert.equal(g.panel.savedChannelRoles.value, 'right_service')
|
||||
assert.equal(g.panel.roleDirty.value, false)
|
||||
assert.equal(g.panel.reviewItems.value[0].selected, false)
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('stereo roles honor permission/status gates and mono saves omit channel_roles', async () => {
|
||||
for (const props of [{ viewOnly: true }, { capabilities: { ...caps(), can_apply: false } }]) {
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, props)
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.equal(f.calls.filter(call => call[0] === 'save').length, 0)
|
||||
f.dispose()
|
||||
}
|
||||
const f = setupPanel()
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
|
||||
assert.equal(f.panel.hasStereo.value, false)
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.equal(Object.hasOwn(f.calls.find(call => call[0] === 'save')[1], 'channel_roles'), false)
|
||||
f.dispose()
|
||||
const g = setupPanel({ followupAudioDetail: async () => stereoTask({ status: 'applied', channel_roles: 'left_service' }) })
|
||||
await settle(); await g.panel.openTask(1); g.panel.setChannelRoles('right_service')
|
||||
assert.equal(g.panel.channelRoles.value, 'left_service')
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('permission or unsaved roles changed during apply confirmation prevent sending apply', async () => {
|
||||
for (const change of [f => f.panel.setChannelRoles('right_service'), f => { f.props.viewOnly = true }]) {
|
||||
const confirm = deferred()
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'left_service' }) }, {}, { confirm: () => confirm.promise })
|
||||
await settle(); await f.panel.openTask(1)
|
||||
const applying = f.panel.applySelected(); await settle(); change(f); confirm.resolve(true); await applying
|
||||
assert.equal(f.calls.filter(call => call[0] === 'apply').length, 0)
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test('old stereo role save/detail completions cannot populate a new patient or emit apply', async () => {
|
||||
const pending = deferred()
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioSaveDraft: async () => pending.promise })
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service')
|
||||
const saving = f.panel.saveDraft(); await settle()
|
||||
f.props.diagnosisId = 20; await settle()
|
||||
pending.resolve(stereoTask({ version: 2, channel_roles: 'left_service' })); await saving
|
||||
assert.equal(f.panel.detail.value, null)
|
||||
assert.equal(f.panel.channelRoles.value, 'unconfirmed')
|
||||
assert.equal(f.panel.roleDirty.value, false)
|
||||
assert.deepEqual(f.panel.transcriptSegments.value, [])
|
||||
assert.deepEqual(f.events, [])
|
||||
assert.deepEqual(f.notices, [])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('WebAudio real routing helper connects only chosen splitter output to both speakers, switches and disposes', async () => {
|
||||
const f = fakeAudioContext()
|
||||
const playback = playbackModule.createFollowupAudioPlayback(f.element, () => f.context)
|
||||
assert.equal(await playback.select('original'), true)
|
||||
assert.equal(f.events.filter(event => event[0] === 'media').length, 0, 'legacy original playback does not require Web Audio')
|
||||
for (const channel of [0, 1]) {
|
||||
assert.equal(await playback.select(channel), true)
|
||||
const media = f.nodes.find(node => node.name === 'media'), split = f.nodes.find(node => node.name === 'splitter'), merge = f.nodes.find(node => node.name === 'merger')
|
||||
assert.deepEqual(media.edges.map(edge => edge.target.name), ['splitter'])
|
||||
assert.deepEqual(split.edges.map(({ target, output, input }) => [target.name, output, input]), [['merger', channel, 0], ['merger', channel, 1]])
|
||||
assert.deepEqual(merge.edges.map(edge => edge.target.name), ['destination'])
|
||||
}
|
||||
assert.equal(f.events.filter(event => event[0] === 'media').length, 1)
|
||||
assert.equal(await playback.select('original'), true)
|
||||
assert.deepEqual(f.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
|
||||
assert.deepEqual(f.nodes.find(node => node.name === 'splitter').edges, [])
|
||||
playback.dispose(); playback.dispose()
|
||||
assert.ok(f.nodes.every(node => node.edges.length === 0))
|
||||
assert.equal(f.events.filter(event => event[0] === 'close').length, 1)
|
||||
assert.equal(await playback.select(0), false)
|
||||
})
|
||||
|
||||
test('WebAudio unsupported/resume failure never claims isolated playback and pending resume is fenced after dispose', async () => {
|
||||
const unsupported = fakeAudioContext()
|
||||
const missing = playbackModule.createFollowupAudioPlayback(unsupported.element, () => null)
|
||||
await assert.rejects(missing.select(0), /无法隔离声道/)
|
||||
assert.equal(unsupported.element.paused, true)
|
||||
assert.equal(await missing.select('original'), true)
|
||||
missing.dispose()
|
||||
const failed = fakeAudioContext({ resume: async () => { throw new Error('NotAllowedError') } })
|
||||
const route = playbackModule.createFollowupAudioPlayback(failed.element, () => failed.context)
|
||||
await assert.rejects(route.select(1), /无法隔离声道/)
|
||||
assert.deepEqual(failed.nodes.find(node => node.name === 'media').edges.map(edge => edge.target.name), ['destination'])
|
||||
assert.equal(failed.events.filter(event => event[0] === 'play').length, 0)
|
||||
route.dispose()
|
||||
const resumed = deferred(), pending = fakeAudioContext({ resume: () => resumed.promise })
|
||||
const delayed = playbackModule.createFollowupAudioPlayback(pending.element, () => pending.context)
|
||||
const selecting = delayed.select(1); delayed.dispose(); resumed.resolve(); assert.equal(await selecting, false)
|
||||
assert.ok(pending.nodes.every(node => node.edges.length === 0))
|
||||
})
|
||||
|
||||
test('actual panel selects evidence channel, seeks original timeline and releases routing on hide and task switch', async () => {
|
||||
const audio = fakeAudioContext()
|
||||
const f = setupPanel({ followupAudioDetail: async id => stereoTask({ id, channel_roles: 'left_service' }) }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
await f.panel.loadAudio(17000, 1)
|
||||
assert.equal(f.panel.playbackChannel.value, 1)
|
||||
assert.equal(audio.element.currentTime, 17)
|
||||
assert.equal(audio.element.paused, false)
|
||||
assert.deepEqual(f.calls.filter(call => call[0] === 'audio'), [['audio', 1]])
|
||||
assert.equal(f.panel.playbackError.value, '')
|
||||
await f.panel.playChannel(0)
|
||||
assert.equal(f.panel.playbackChannel.value, 0)
|
||||
assert.equal(f.calls.filter(call => call[0] === 'audio').length, 1)
|
||||
f.props.active = false; await settle()
|
||||
assert.equal(audio.element.paused, true)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.equal(f.panel.audioUrl.value, '')
|
||||
assert.deepEqual(f.revoked, ['blob:private-0'])
|
||||
f.props.active = true; await settle(); await f.panel.openTask(2)
|
||||
assert.equal(f.panel.selectedId.value, 2)
|
||||
assert.equal(f.panel.channelLoading.value, false)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('actual panel reports unavailable isolation, keeps original playback and never falls back to mixed autoplay', async () => {
|
||||
const audio = fakeAudioContext()
|
||||
const unsupported = { createFollowupAudioPlayback: element => playbackModule.createFollowupAudioPlayback(element, () => null) }
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: unsupported })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
await f.panel.loadAudio(1000, 0)
|
||||
assert.match(f.panel.playbackError.value, /无法隔离声道/)
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.equal(audio.element.paused, true)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'play').length, 0)
|
||||
await f.panel.playChannel('original')
|
||||
assert.equal(f.panel.playbackError.value, '')
|
||||
assert.equal(audio.element.paused, false)
|
||||
f.dispose()
|
||||
const mono = setupPanel()
|
||||
await settle(); await mono.panel.openTask(1); mono.panel.audioElement.value = audio.element
|
||||
await mono.panel.loadAudio(1000, 1)
|
||||
assert.match(mono.panel.playbackError.value, /未取得可验证的双声道/)
|
||||
assert.equal(mono.calls.filter(call => call[0] === 'audio').length, 0)
|
||||
mono.dispose()
|
||||
})
|
||||
|
||||
test('late stereo blob and routing resume do not play or relabel another patient', async () => {
|
||||
const pending = deferred(), audio = fakeAudioContext({ resume: () => pending.promise })
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
const play = f.panel.loadAudio(5000, 1); await settle()
|
||||
assert.equal(f.panel.channelLoading.value, true)
|
||||
f.props.diagnosisId = 20; await settle(); pending.resolve(); await play
|
||||
assert.equal(f.panel.audioUrl.value, '')
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.equal(f.panel.channelLoading.value, false)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'play').length, 0)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
|
||||
f.dispose()
|
||||
const blob = deferred(), g = setupPanel({ followupAudioDetail: async () => stereoTask(), followupAudioBlob: async () => blob.promise }, {}, { playback: audio.playback })
|
||||
await settle(); await g.panel.openTask(1); g.panel.audioElement.value = audio.element
|
||||
const loading = g.panel.loadAudio(5000, 0); await settle(); g.props.diagnosisId = 30; await settle()
|
||||
blob.resolve(new Blob(['synthetic'])); await loading
|
||||
assert.deepEqual(g.urls, [])
|
||||
assert.equal(g.panel.detail.value, null)
|
||||
g.dispose()
|
||||
})
|
||||
|
||||
test('channel evidence is immutable through editor/draft and seek emits channel without guessing a role', async () => {
|
||||
const quote = { text: '合成右侧原话', segment_id: 'right-1', start_ms: 15000, end_ms: 30000, position_type: 'segment', channel: 1 }
|
||||
const f = setupReview({ items: [item({ evidence: [quote] })], channelRoles: 'unconfirmed' })
|
||||
f.review.openReview('blood-1'); f.review.seekEvidence(f.review.editor.value.evidence[0])
|
||||
assert.deepEqual(f.events, [['seek', 15000, 1]])
|
||||
assert.equal(state.evidencePlaybackLabel(quote), '回听片段 0:15–0:30 · 右声道')
|
||||
f.review.editor.value.evidence[0].channel = 0
|
||||
f.review.setValue('fasting_blood_sugar', 7); f.review.confirmReview(true); f.review.saveReview(true)
|
||||
assert.deepEqual(f.props.items[0].evidence, [quote])
|
||||
const panel = setupPanel({ followupAudioDetail: async () => stereoTask({ channel_roles: 'right_service', items: f.props.items }) })
|
||||
await settle(); await panel.panel.openTask(1); await panel.panel.saveDraft()
|
||||
assert.equal(panel.calls.find(call => call[0] === 'save')[1].items[0].evidence[0].channel, 1)
|
||||
f.dispose(); panel.dispose()
|
||||
})
|
||||
|
||||
test('API forwards optional channel_roles only in draft and stereo apply uses server roles after explicit re-review', async () => {
|
||||
const requests = []
|
||||
const api = loadTs(path.join(root, 'src/api/followupAudio.ts'), () => ({ default: { post: async (config, options) => { requests.push({ config, options }); return { code: 1, data: stereoTask({ channel_roles: 'left_service' }) } } } }))
|
||||
await api.followupAudioSaveDraft({ id: 1, version: 1, items: [item()], channel_roles: 'left_service' })
|
||||
assert.equal(requests[0].config.url, '/tcm.followupAudio/saveDraft')
|
||||
assert.equal(requests[0].config.data.channel_roles, 'left_service')
|
||||
assert.equal(requests[0].options.isOpenRetry, false)
|
||||
let current = stereoTask()
|
||||
const applied = []
|
||||
const f = setupPanel({ followupAudioDetail: async () => current, followupAudioSaveDraft: async payload => {
|
||||
current = { ...current, version: 2, channel_roles: payload.channel_roles, items: payload.items.map(row => ({ ...row, selected: false, needs_review: true })) }
|
||||
return current
|
||||
}, followupAudioApply: async payload => { applied.push(payload); current = { ...current, status: 'applied', items: payload.items }; return { id: 1, status: 'applied', applied_items: [] } } })
|
||||
await settle(); await f.panel.openTask(1); f.panel.setChannelRoles('left_service'); await f.panel.saveDraft()
|
||||
await f.panel.applySelected(); assert.equal(applied.length, 0)
|
||||
const review = setupReview({ items: f.panel.reviewItems.value })
|
||||
review.review.openReview('blood-1'); review.review.confirmReview(true); review.review.saveReview(true)
|
||||
f.panel.reviewItems.value = review.props.items
|
||||
await f.panel.applySelected()
|
||||
assert.equal(applied.length, 1)
|
||||
assert.equal(applied[0].version, 2)
|
||||
assert.equal(Object.hasOwn(applied[0], 'channel_roles'), false, 'apply uses authoritative server-saved role mapping')
|
||||
assert.equal(f.panel.detail.value.status, 'applied')
|
||||
assert.deepEqual(f.events, [['applied', 10]])
|
||||
review.dispose(); f.dispose()
|
||||
})
|
||||
|
||||
test('rapid opposing evidence seeks use only the newest channel and original-audio reload closes the old graph', async () => {
|
||||
const resume = deferred(), audio = fakeAudioContext({ resume: () => resume.promise })
|
||||
const f = setupPanel({ followupAudioDetail: async () => stereoTask() }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element
|
||||
const first = f.panel.loadAudio(2000, 0); await settle()
|
||||
const second = f.panel.loadAudio(9000, 1); await settle(); resume.resolve(); await Promise.all([first, second])
|
||||
assert.equal(f.panel.playbackChannel.value, 1)
|
||||
assert.equal(audio.element.currentTime, 9)
|
||||
assert.equal(audio.events.filter(event => event[0] === 'play').length, 1)
|
||||
assert.deepEqual(audio.nodes.find(node => node.name === 'splitter').edges.map(edge => edge.output), [1, 1])
|
||||
await f.panel.reloadAudio()
|
||||
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
assert.deepEqual(f.revoked, ['blob:private-0'])
|
||||
assert.equal(f.calls.filter(call => call[0] === 'audio').length, 2)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('task switch and observed audio expiry both close live stereo routing and revoke the authorized blob', async () => {
|
||||
for (const transition of ['task', 'expired']) {
|
||||
const audio = fakeAudioContext()
|
||||
let current = stereoTask({ channel_roles: 'left_service' })
|
||||
const f = setupPanel({ followupAudioDetail: async id => ({ ...current, id }), followupAudioLists: async () => ({ items: [current] }) }, {}, { playback: audio.playback })
|
||||
await settle(); await f.panel.openTask(1); f.panel.audioElement.value = audio.element; await f.panel.loadAudio(1000, 1)
|
||||
if (transition === 'task') await f.panel.openTask(2)
|
||||
else {
|
||||
current = { ...current, version: 2, status: 'expired', audio_available: false, transcript: '', transcript_segments: [] }
|
||||
f.tick(); await settle()
|
||||
assert.deepEqual(f.panel.transcriptSegments.value, [])
|
||||
}
|
||||
assert.equal(audio.events.filter(event => event[0] === 'close').length, 1)
|
||||
assert.equal(audio.element.paused, true)
|
||||
assert.deepEqual(f.revoked, ['blob:private-0'])
|
||||
assert.equal(f.panel.audioUrl.value, '')
|
||||
assert.equal(f.panel.playbackChannel.value, 'original')
|
||||
f.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user