fix(templates): route dictation results into active template sessions
This commit is contained in:
parent
11bf995409
commit
115e8488ac
7 changed files with 386 additions and 15 deletions
186
apps/desktop/tests/red/templates-wiring-redteam-r2-15.test.ts
Normal file
186
apps/desktop/tests/red/templates-wiring-redteam-r2-15.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// 받아쓰기 템플릿 세션 배선 회귀 테스트 (redteam r2-15)
|
||||
// 버그: 템플릿 세션을 시작해도 받아쓰기 결과가 필드로 들어가지 않아 세션이 영구히 남았다.
|
||||
// 이제 VoiceMode 완료 경로(persistCompletedVoiceSessionSafe)가 결과를 템플릿 세션으로 라우팅하고,
|
||||
// 완성된 결과는 출력 포트로 전달된다.
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getDictationTemplateService,
|
||||
resetDictationTemplateServiceForTests,
|
||||
} from '../../src/main/services/DictationTemplateService'
|
||||
import type { TemplateOutputPort } from '../../src/main/services/dictation-template-output'
|
||||
import {
|
||||
dictatedFieldValue,
|
||||
nextTemplateField,
|
||||
renderTemplateOutput,
|
||||
} from '../../src/main/services/dictation-template-policy'
|
||||
import {
|
||||
isVoiceSessionClaimed,
|
||||
registerVoiceTextSink,
|
||||
resetVoiceTextRoutingForTests,
|
||||
routeCompletedVoiceText,
|
||||
} from '../../src/main/voice-text-routing'
|
||||
import {
|
||||
persistCompletedVoiceSessionSafe,
|
||||
type PersistableVoiceSession,
|
||||
} from '../../src/main/voice-session-persist'
|
||||
import { getHistoryService } from '../../src/main/services/HistoryService'
|
||||
import { useRedHarness } from './harness'
|
||||
import type { DictationTemplate, TemplateSessionInfo } from '@d3ro/core/types'
|
||||
|
||||
useRedHarness()
|
||||
|
||||
afterEach(() => {
|
||||
resetVoiceTextRoutingForTests()
|
||||
})
|
||||
|
||||
function voiceSession(id: string, text: string): PersistableVoiceSession {
|
||||
return { id, transcription: text, processedText: null, mode: 'dictation', startedAt: Date.now() }
|
||||
}
|
||||
|
||||
function fakeOutput(): TemplateOutputPort & { delivered: string[] } {
|
||||
const delivered: string[] = []
|
||||
return {
|
||||
delivered,
|
||||
deliver: vi.fn(async (text: string) => {
|
||||
delivered.push(text)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function completeVoice(id: string, text: string): void {
|
||||
persistCompletedVoiceSessionSafe(() => undefined, voiceSession(id, text), text)
|
||||
}
|
||||
|
||||
describe('받아쓰기 템플릿 세션 ← VoiceMode 완료 배선', () => {
|
||||
it('세션 중 받아쓰기 결과가 필드를 차례로 채우고, 완성 결과가 출력 포트로 전달된다', async () => {
|
||||
const output = fakeOutput()
|
||||
resetDictationTemplateServiceForTests(undefined, output)
|
||||
const svc = getDictationTemplateService()
|
||||
const completed: string[] = []
|
||||
svc.on('session-completed', (e: { outputText: string }) => completed.push(e.outputText))
|
||||
|
||||
svc.startSession('builtin-email')
|
||||
completeVoice('r2-15-a', ' kim@example.test ')
|
||||
expect(svc.getSessionState()?.currentField?.id).toBe('subject')
|
||||
completeVoice('r2-15-b', 'Quarterly plan')
|
||||
expect(svc.getSessionState()?.currentField?.id).toBe('body')
|
||||
completeVoice('r2-15-c', 'See attached.')
|
||||
|
||||
expect(svc.getSessionState()).toBeNull()
|
||||
expect(completed).toEqual(['To: kim@example.test\nSubject: Quarterly plan\n\nSee attached.'])
|
||||
await vi.waitFor(() => expect(output.delivered).toEqual(completed))
|
||||
|
||||
// 소비된 세션은 일반 삽입 경로가 건너뛸 수 있도록 표시된다
|
||||
expect(isVoiceSessionClaimed('r2-15-a')).toBe(true)
|
||||
expect(isVoiceSessionClaimed('r2-15-c')).toBe(true)
|
||||
// 히스토리 저장은 그대로 일어난다
|
||||
expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(3)
|
||||
})
|
||||
|
||||
it('템플릿 세션이 없으면 결과를 가져가지 않는다 (일반 삽입 경로 유지)', () => {
|
||||
resetDictationTemplateServiceForTests(undefined, fakeOutput())
|
||||
getDictationTemplateService()
|
||||
completeVoice('r2-15-none', 'plain dictation')
|
||||
expect(isVoiceSessionClaimed('r2-15-none')).toBe(false)
|
||||
expect(getHistoryService().list({ page: 0, pageSize: 10 }).total).toBe(1)
|
||||
})
|
||||
|
||||
it('빈 받아쓰기 결과는 필드를 채우지 않는다', () => {
|
||||
resetDictationTemplateServiceForTests(undefined, fakeOutput())
|
||||
const svc = getDictationTemplateService()
|
||||
svc.startSession('builtin-email')
|
||||
expect(routeCompletedVoiceText('r2-15-empty', ' ')).toBe(false)
|
||||
expect(svc.getSessionState()?.currentField?.id).toBe('recipient')
|
||||
})
|
||||
|
||||
it('같은 VoiceMode 세션을 두 번 라우팅해도 필드는 한 번만 채워진다', () => {
|
||||
resetDictationTemplateServiceForTests(undefined, fakeOutput())
|
||||
const svc = getDictationTemplateService()
|
||||
svc.startSession('builtin-email')
|
||||
expect(routeCompletedVoiceText('r2-15-dup', 'a@b.test')).toBe(true)
|
||||
expect(routeCompletedVoiceText('r2-15-dup', 'a@b.test')).toBe(true)
|
||||
expect(svc.getSessionState()?.currentField?.id).toBe('subject')
|
||||
expect(svc.getSessionState()?.fieldValues).toEqual({ recipient: 'a@b.test' })
|
||||
})
|
||||
|
||||
it('서비스를 dispose하면 소비자 등록이 풀린다', () => {
|
||||
resetDictationTemplateServiceForTests(undefined, fakeOutput())
|
||||
const svc = getDictationTemplateService()
|
||||
svc.startSession('builtin-email')
|
||||
resetDictationTemplateServiceForTests(undefined, fakeOutput())
|
||||
expect(routeCompletedVoiceText('r2-15-disposed', 'text')).toBe(false)
|
||||
})
|
||||
|
||||
it('출력 전달이 실패해도 세션은 끝나고 예외가 새지 않는다', async () => {
|
||||
const deliver = vi.fn(async () => {
|
||||
throw new Error('insert failed')
|
||||
})
|
||||
resetDictationTemplateServiceForTests(undefined, { deliver })
|
||||
const svc = getDictationTemplateService()
|
||||
const created = svc.create({
|
||||
name: 'one',
|
||||
description: '',
|
||||
fields: [{ id: 'f1', name: 'f1', label: 'F1', promptText: '', required: true, maxDurationSec: 10 }],
|
||||
outputFormat: '[{{f1}}]',
|
||||
})
|
||||
svc.startSession(created.id)
|
||||
expect(svc.consumeDictatedText('hello')).toBe(true)
|
||||
expect(svc.getSessionState()).toBeNull()
|
||||
await vi.waitFor(() => expect(deliver).toHaveBeenCalledWith('[hello]'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('라우팅 포트', () => {
|
||||
it('던지는 소비자는 건너뛰고 다음 소비자에게 넘긴다', () => {
|
||||
registerVoiceTextSink(() => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
const seen: string[] = []
|
||||
registerVoiceTextSink(({ text }) => {
|
||||
seen.push(text)
|
||||
return true
|
||||
})
|
||||
expect(routeCompletedVoiceText('r2-15-sink', 'x')).toBe(true)
|
||||
expect(seen).toEqual(['x'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('받아쓰기 템플릿 정책', () => {
|
||||
it('자리표시자를 값으로 바꾸고 미입력 자리표시자는 지운다', () => {
|
||||
expect(renderTemplateOutput('To: {{a}}\n{{b}}\n', { a: 'x' })).toBe('To: x')
|
||||
})
|
||||
|
||||
it('값 안의 치환 패턴($&)과 중괄호는 그대로 남는다', () => {
|
||||
expect(renderTemplateOutput('{{a}}|{{b}}', { a: 'cost $& more', b: '{{a}}' })).toBe('cost $& more|{{a}}')
|
||||
})
|
||||
|
||||
it('정규식 특수문자가 든 필드 id도 치환된다', () => {
|
||||
expect(renderTemplateOutput('<{{a.b(1)}}>', { 'a.b(1)': 'ok' })).toBe('<ok>')
|
||||
})
|
||||
|
||||
it('다음 필드와 필드 값 판정', () => {
|
||||
const template = {
|
||||
fields: [
|
||||
{ id: 'f1', name: 'f1', label: 'F1', promptText: '', required: true, maxDurationSec: 1 },
|
||||
{ id: 'f2', name: 'f2', label: 'F2', promptText: '', required: true, maxDurationSec: 1 },
|
||||
],
|
||||
} as unknown as DictationTemplate
|
||||
expect(nextTemplateField(template, 0)?.id).toBe('f2')
|
||||
expect(nextTemplateField(template, 1)).toBeNull()
|
||||
|
||||
const session: TemplateSessionInfo = {
|
||||
templateId: 't',
|
||||
templateName: 'T',
|
||||
state: 'field-prompting',
|
||||
currentFieldIndex: 0,
|
||||
totalFields: 2,
|
||||
currentField: template.fields[0],
|
||||
fieldValues: {},
|
||||
}
|
||||
expect(dictatedFieldValue(session, ' hi ')).toEqual({ fieldId: 'f1', value: 'hi' })
|
||||
expect(dictatedFieldValue(session, ' ')).toBeNull()
|
||||
expect(dictatedFieldValue({ ...session, state: 'completing' }, 'hi')).toBeNull()
|
||||
expect(dictatedFieldValue(null, 'hi')).toBeNull()
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue