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