fix(templates): route dictation results into active template sessions

This commit is contained in:
Yun Chan 2026-09-28 02:16:19 +09:00
parent 11bf995409
commit 115e8488ac
7 changed files with 386 additions and 15 deletions

View 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()
})
})