// 받아쓰기 템플릿 세션 배선 회귀 테스트 (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('') }) 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() }) })