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
|
|
@ -14,6 +14,9 @@ import {
|
||||||
type UserScopedCollectionFactory,
|
type UserScopedCollectionFactory,
|
||||||
} from './account-scope'
|
} from './account-scope'
|
||||||
import { getMainWindow } from '../windows/WindowManager'
|
import { getMainWindow } from '../windows/WindowManager'
|
||||||
|
import { registerVoiceTextSink } from '../voice-text-routing'
|
||||||
|
import { dictatedFieldValue, nextTemplateField, renderTemplateOutput } from './dictation-template-policy'
|
||||||
|
import { textInsertTemplateOutputPort, type TemplateOutputPort } from './dictation-template-output'
|
||||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -80,9 +83,15 @@ class DictationTemplateService extends EventEmitter {
|
||||||
private _items: DictationTemplate[] = []
|
private _items: DictationTemplate[] = []
|
||||||
private readonly _unsubscribeScope: () => void
|
private readonly _unsubscribeScope: () => void
|
||||||
private _session: TemplateSessionInfo | null = null
|
private _session: TemplateSessionInfo | null = null
|
||||||
|
private readonly _output: TemplateOutputPort
|
||||||
|
private readonly _unregisterVoiceSink: () => void
|
||||||
|
|
||||||
constructor(factory: UserScopedCollectionFactory = collectionFactory) {
|
constructor(
|
||||||
|
factory: UserScopedCollectionFactory = collectionFactory,
|
||||||
|
output: TemplateOutputPort = outputPort,
|
||||||
|
) {
|
||||||
super()
|
super()
|
||||||
|
this._output = output
|
||||||
this._list = new AccountScopedList<DictationTemplate>({
|
this._list = new AccountScopedList<DictationTemplate>({
|
||||||
name: 'dictation-templates',
|
name: 'dictation-templates',
|
||||||
factory,
|
factory,
|
||||||
|
|
@ -90,6 +99,8 @@ class DictationTemplateService extends EventEmitter {
|
||||||
})
|
})
|
||||||
this._loadScope(currentAccountScope())
|
this._loadScope(currentAccountScope())
|
||||||
this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
|
this._unsubscribeScope = onAccountScopeChanged((scopeId) => this._loadScope(scopeId))
|
||||||
|
// 진행 중인 템플릿 세션이 있으면 받아쓰기 결과를 현재 필드 값으로 가져간다
|
||||||
|
this._unregisterVoiceSink = registerVoiceTextSink(({ text }) => this.consumeDictatedText(text))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CRUD ──
|
// ── CRUD ──
|
||||||
|
|
@ -227,7 +238,7 @@ class DictationTemplateService extends EventEmitter {
|
||||||
|
|
||||||
const currentField = this._session.currentField
|
const currentField = this._session.currentField
|
||||||
const nextIndex = this._session.currentFieldIndex + 1
|
const nextIndex = this._session.currentFieldIndex + 1
|
||||||
const nextField = nextIndex < template.fields.length ? template.fields[nextIndex] : null
|
const nextField = nextTemplateField(template, this._session.currentFieldIndex)
|
||||||
|
|
||||||
// 필드 완료 이벤트
|
// 필드 완료 이벤트
|
||||||
const fieldEvent: TemplateFieldCompletedEvent = {
|
const fieldEvent: TemplateFieldCompletedEvent = {
|
||||||
|
|
@ -251,6 +262,17 @@ class DictationTemplateService extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 받아쓰기 결과를 현재 필드 값으로 받는다. 필드 입력 대기 중인 세션이 없거나 결과가 비었으면
|
||||||
|
* 아무것도 하지 않고 false를 돌려준다 — 그 결과는 일반 삽입 경로로 간다.
|
||||||
|
*/
|
||||||
|
consumeDictatedText(text: string): boolean {
|
||||||
|
const target = dictatedFieldValue(this._session, text)
|
||||||
|
if (!target) return false
|
||||||
|
this.setFieldValue(target.fieldId, target.value)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
cancelSession(): void {
|
cancelSession(): void {
|
||||||
if (!this._session) return
|
if (!this._session) return
|
||||||
logger.info('Template session cancelled')
|
logger.info('Template session cancelled')
|
||||||
|
|
@ -264,17 +286,10 @@ class DictationTemplateService extends EventEmitter {
|
||||||
this._session.state = 'completing'
|
this._session.state = 'completing'
|
||||||
this._emitSessionState()
|
this._emitSessionState()
|
||||||
|
|
||||||
// 출력 포맷 적용 (mustache-like 치환)
|
// 출력 포맷 적용 (mustache-like 치환, 미입력 자리표시자 제거)
|
||||||
let output = template.outputFormat
|
|
||||||
for (const [key, value] of Object.entries(this._session.fieldValues)) {
|
|
||||||
output = output.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), value)
|
|
||||||
}
|
|
||||||
// 미입력 필드 자리표시자 제거
|
|
||||||
output = output.replace(/\{\{[^}]+\}\}/g, '')
|
|
||||||
|
|
||||||
const completedEvent: TemplateSessionCompletedEvent = {
|
const completedEvent: TemplateSessionCompletedEvent = {
|
||||||
templateId: template.id,
|
templateId: template.id,
|
||||||
outputText: output.trim(),
|
outputText: renderTemplateOutput(template.outputFormat, this._session.fieldValues),
|
||||||
fieldValues: { ...this._session.fieldValues },
|
fieldValues: { ...this._session.fieldValues },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -283,6 +298,11 @@ class DictationTemplateService extends EventEmitter {
|
||||||
|
|
||||||
this._session = null
|
this._session = null
|
||||||
logger.info(`Template session completed: ${template.name}`)
|
logger.info(`Template session completed: ${template.name}`)
|
||||||
|
|
||||||
|
// 완성된 결과를 활성 앱에 전달한다 — 실패해도 세션은 이미 끝났다(렌더러가 결과를 보여 준다)
|
||||||
|
this._output.deliver(completedEvent.outputText).catch((err: unknown) => {
|
||||||
|
logger.warn(`Template output delivery failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private _emitSessionState(): void {
|
private _emitSessionState(): void {
|
||||||
|
|
@ -334,6 +354,7 @@ class DictationTemplateService extends EventEmitter {
|
||||||
|
|
||||||
dispose(): void {
|
dispose(): void {
|
||||||
this._unsubscribeScope()
|
this._unsubscribeScope()
|
||||||
|
this._unregisterVoiceSink()
|
||||||
this.cancelSession()
|
this.cancelSession()
|
||||||
this.removeAllListeners()
|
this.removeAllListeners()
|
||||||
}
|
}
|
||||||
|
|
@ -351,6 +372,7 @@ function readLegacyTemplates(): DictationTemplate[] | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
let collectionFactory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
||||||
|
let outputPort: TemplateOutputPort = textInsertTemplateOutputPort
|
||||||
|
|
||||||
// ── 싱글톤 ──
|
// ── 싱글톤 ──
|
||||||
let instance: DictationTemplateService | null = null
|
let instance: DictationTemplateService | null = null
|
||||||
|
|
@ -363,9 +385,11 @@ export function getDictationTemplateService(): DictationTemplateService {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetDictationTemplateServiceForTests(
|
export function resetDictationTemplateServiceForTests(
|
||||||
factory: UserScopedCollectionFactory = electronStoreCollectionFactory
|
factory: UserScopedCollectionFactory = electronStoreCollectionFactory,
|
||||||
|
output: TemplateOutputPort = textInsertTemplateOutputPort,
|
||||||
): void {
|
): void {
|
||||||
instance?.dispose()
|
instance?.dispose()
|
||||||
instance = null
|
instance = null
|
||||||
collectionFactory = factory
|
collectionFactory = factory
|
||||||
|
outputPort = output
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
apps/desktop/src/main/services/dictation-template-output.ts
Normal file
19
apps/desktop/src/main/services/dictation-template-output.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
// 받아쓰기 템플릿 결과 전달 포트. 기본 구현은 활성 앱에 텍스트를 삽입한다.
|
||||||
|
// 서비스는 이 인터페이스에만 기대고, 테스트는 가짜 포트를 넣는다.
|
||||||
|
|
||||||
|
export interface TemplateOutputPort {
|
||||||
|
/** 완성된 템플릿 결과를 사용자에게 전달한다. 실패하면 reject한다. */
|
||||||
|
deliver(text: string): Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 일반 받아쓰기와 같은 설정(autoInsert, insertMethod)으로 TextInsertService를 통해 삽입한다. */
|
||||||
|
export const textInsertTemplateOutputPort: TemplateOutputPort = {
|
||||||
|
async deliver(text: string): Promise<void> {
|
||||||
|
if (text.length === 0) return
|
||||||
|
// 지연 import — 네이티브 키보드 모듈을 템플릿 CRUD 경로에서 끌어오지 않는다
|
||||||
|
const { configGet } = await import('./ConfigService')
|
||||||
|
if (!configGet('autoInsert')) return
|
||||||
|
const { getTextInsertService } = await import('./TextInsertService')
|
||||||
|
await getTextInsertService().insertText(text, configGet('insertMethod'))
|
||||||
|
},
|
||||||
|
}
|
||||||
37
apps/desktop/src/main/services/dictation-template-policy.ts
Normal file
37
apps/desktop/src/main/services/dictation-template-policy.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// 받아쓰기 템플릿 순수 정책 — IO 없음. DictationTemplateService와 테스트가 함께 쓴다.
|
||||||
|
|
||||||
|
import type { DictationTemplate, TemplateField, TemplateSessionInfo } from '@d3ro/core/types'
|
||||||
|
|
||||||
|
const PLACEHOLDER = /\{\{([^}]+)\}\}/g
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 출력 포맷의 {{필드 id}} 자리표시자를 값으로 바꾼다. 값이 없는 자리표시자는 지운다.
|
||||||
|
* 한 번의 치환으로 처리하므로 값 안의 `$&`, `{{...}}`, 정규식 특수문자가 든 필드 id도 그대로 다룬다.
|
||||||
|
*/
|
||||||
|
export function renderTemplateOutput(format: string, fieldValues: Readonly<Record<string, string>>): string {
|
||||||
|
return format
|
||||||
|
.replace(PLACEHOLDER, (_match, key: string) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(fieldValues, key) ? fieldValues[key] : '',
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 현재 필드 다음 필드. 마지막이면 null. */
|
||||||
|
export function nextTemplateField(template: DictationTemplate, currentIndex: number): TemplateField | null {
|
||||||
|
const nextIndex = currentIndex + 1
|
||||||
|
return nextIndex < template.fields.length ? template.fields[nextIndex] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 받아쓰기 결과를 현재 필드 값으로 받을지 판단한다. 받을 수 없으면 null.
|
||||||
|
* 세션이 필드 입력 대기 중이고, 공백을 뺀 결과가 비어 있지 않을 때만 받는다.
|
||||||
|
*/
|
||||||
|
export function dictatedFieldValue(
|
||||||
|
session: TemplateSessionInfo | null,
|
||||||
|
text: string,
|
||||||
|
): { fieldId: string; value: string } | null {
|
||||||
|
if (!session || session.state !== 'field-prompting' || !session.currentField) return null
|
||||||
|
const value = text.trim()
|
||||||
|
if (value.length === 0) return null
|
||||||
|
return { fieldId: session.currentField.id, value }
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
// VoiceMode session-completed → History 저장. bootstrap과 테스트가 같은 함수를 호출한다.
|
// VoiceMode session-completed → 결과 라우팅(템플릿 필드 등) + History 저장.
|
||||||
|
// bootstrap과 테스트가 같은 함수를 호출한다.
|
||||||
|
|
||||||
import { join } from 'path'
|
import { join } from 'path'
|
||||||
import { app } from 'electron'
|
import { app } from 'electron'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import { getHistoryService } from './services/HistoryService'
|
import { getHistoryService } from './services/HistoryService'
|
||||||
import { getLogger } from './services/LoggerService'
|
import { getLogger } from './services/LoggerService'
|
||||||
|
import { routeCompletedVoiceText } from './voice-text-routing'
|
||||||
import type { HistoryEntry } from '@d3ro/core/types'
|
import type { HistoryEntry } from '@d3ro/core/types'
|
||||||
|
|
||||||
const logger = getLogger('voice-persist')
|
const logger = getLogger('voice-persist')
|
||||||
|
|
@ -37,11 +39,21 @@ export function persistCompletedVoiceSession(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완료된 받아쓰기 결과를 소비자(진행 중인 받아쓰기 템플릿 세션의 현재 필드 등)에게 넘긴다.
|
||||||
|
* 소비되면 true — 일반 삽입 경로는 isVoiceSessionClaimed(session.id)로 이를 확인한다.
|
||||||
|
*/
|
||||||
|
export function routeCompletedVoiceSession(session: PersistableVoiceSession, finalText: string): boolean {
|
||||||
|
return routeCompletedVoiceText(session.id, finalText)
|
||||||
|
}
|
||||||
|
|
||||||
export function persistCompletedVoiceSessionSafe(
|
export function persistCompletedVoiceSessionSafe(
|
||||||
emitError: (error: D3ROError, session: PersistableVoiceSession) => void,
|
emitError: (error: D3ROError, session: PersistableVoiceSession) => void,
|
||||||
session: PersistableVoiceSession,
|
session: PersistableVoiceSession,
|
||||||
finalText: string,
|
finalText: string,
|
||||||
): HistoryEntry | null {
|
): HistoryEntry | null {
|
||||||
|
// 히스토리 저장이 실패해도 결과는 소비자에게 전달돼야 하므로 먼저 라우팅한다
|
||||||
|
routeCompletedVoiceSession(session, finalText)
|
||||||
try {
|
try {
|
||||||
return persistCompletedVoiceSession(session, finalText)
|
return persistCompletedVoiceSession(session, finalText)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
62
apps/desktop/src/main/voice-text-routing.ts
Normal file
62
apps/desktop/src/main/voice-text-routing.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// 받아쓰기 결과 라우팅 포트.
|
||||||
|
// VoiceMode 세션이 끝나면 전사 결과를 여기로 넘긴다. 등록된 소비자(sink)가 결과를 가져가면
|
||||||
|
// (예: 진행 중인 받아쓰기 템플릿 세션의 현재 필드) 그 세션은 "소비됨"으로 기록된다.
|
||||||
|
// 일반 삽입 경로는 isVoiceSessionClaimed(sessionId)로 소비 여부를 확인하고 삽입을 건너뛸 수 있다.
|
||||||
|
// 이 모듈은 소비자 구현을 모른다 — 소비자가 스스로 등록한다(의존성 역전).
|
||||||
|
|
||||||
|
import { getLogger } from './services/LoggerService'
|
||||||
|
|
||||||
|
const logger = getLogger('voice-text-routing')
|
||||||
|
|
||||||
|
export interface VoiceTextOffer {
|
||||||
|
sessionId: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 결과를 가져가면 true. 가져가지 않으면 false — 다음 소비자에게 넘어간다. */
|
||||||
|
export type VoiceTextSink = (offer: VoiceTextOffer) => boolean
|
||||||
|
|
||||||
|
/** 소비 기록 보존 개수 — 최근 세션만 기억하면 된다. */
|
||||||
|
const MAX_CLAIMED_SESSIONS = 32
|
||||||
|
|
||||||
|
const sinks = new Set<VoiceTextSink>()
|
||||||
|
const claimedSessions: string[] = []
|
||||||
|
|
||||||
|
export function registerVoiceTextSink(sink: VoiceTextSink): () => void {
|
||||||
|
sinks.add(sink)
|
||||||
|
return () => {
|
||||||
|
sinks.delete(sink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 완료된 받아쓰기 결과를 소비자에게 제안한다. 같은 세션을 두 번 넘겨도 한 번만 소비된다.
|
||||||
|
* @returns 소비자가 가져갔으면 true
|
||||||
|
*/
|
||||||
|
export function routeCompletedVoiceText(sessionId: string, text: string): boolean {
|
||||||
|
if (claimedSessions.includes(sessionId)) return true
|
||||||
|
for (const sink of [...sinks]) {
|
||||||
|
let claimed = false
|
||||||
|
try {
|
||||||
|
claimed = sink({ sessionId, text })
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Voice text sink failed: ${err instanceof Error ? err.message : String(err)}`)
|
||||||
|
}
|
||||||
|
if (claimed) {
|
||||||
|
claimedSessions.push(sessionId)
|
||||||
|
if (claimedSessions.length > MAX_CLAIMED_SESSIONS) claimedSessions.shift()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이 세션의 결과를 소비자가 가져갔는지 — 일반 삽입을 건너뛸지 판단할 때 쓴다. */
|
||||||
|
export function isVoiceSessionClaimed(sessionId: string): boolean {
|
||||||
|
return claimedSessions.includes(sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetVoiceTextRoutingForTests(): void {
|
||||||
|
sinks.clear()
|
||||||
|
claimedSessions.length = 0
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,8 @@ export function TemplateSection(): React.ReactElement {
|
||||||
const [formOutput, setFormOutput] = useState('')
|
const [formOutput, setFormOutput] = useState('')
|
||||||
const [formFields, setFormFields] = useState<TemplateField[]>([])
|
const [formFields, setFormFields] = useState<TemplateField[]>([])
|
||||||
const [session, setSession] = useState<TemplateSessionInfo | null>(null)
|
const [session, setSession] = useState<TemplateSessionInfo | null>(null)
|
||||||
|
/** 마지막으로 완성된 템플릿 결과 — 활성 앱 삽입이 꺼져 있거나 실패해도 복사할 수 있게 보여 준다 */
|
||||||
|
const [lastOutput, setLastOutput] = useState<string | null>(null)
|
||||||
|
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const isSavingRef = useRef(false)
|
const isSavingRef = useRef(false)
|
||||||
|
|
@ -50,8 +52,9 @@ export function TemplateSection(): React.ReactElement {
|
||||||
const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => {
|
const unsub = window.electronAPI.dictationTemplate.onSessionStateChanged((data) => {
|
||||||
setSession(data)
|
setSession(data)
|
||||||
})
|
})
|
||||||
const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted(() => {
|
const unsubComplete = window.electronAPI.dictationTemplate.onSessionCompleted((data) => {
|
||||||
setSession(null)
|
setSession(null)
|
||||||
|
setLastOutput(data.outputText.length > 0 ? data.outputText : null)
|
||||||
loadData()
|
loadData()
|
||||||
})
|
})
|
||||||
return () => { unsub(); unsubComplete() }
|
return () => { unsub(); unsubComplete() }
|
||||||
|
|
@ -118,9 +121,14 @@ export function TemplateSection(): React.ReactElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleStartSession = async (templateId: string) => {
|
const handleStartSession = async (templateId: string) => {
|
||||||
|
setLastOutput(null)
|
||||||
await window.electronAPI.dictationTemplate.startSession({ templateId })
|
await window.electronAPI.dictationTemplate.startSession({ templateId })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCopyOutput = () => {
|
||||||
|
if (lastOutput) void navigator.clipboard.writeText(lastOutput)
|
||||||
|
}
|
||||||
|
|
||||||
const handleCancelSession = async () => {
|
const handleCancelSession = async () => {
|
||||||
await window.electronAPI.dictationTemplate.cancelSession()
|
await window.electronAPI.dictationTemplate.cancelSession()
|
||||||
setSession(null)
|
setSession(null)
|
||||||
|
|
@ -176,6 +184,29 @@ export function TemplateSection(): React.ReactElement {
|
||||||
{t('template.cancelSession')}
|
{t('template.cancelSession')}
|
||||||
</PhysicalButton>
|
</PhysicalButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
{/* 현재 필드의 음성 프롬프트 — 받아쓰기 결과가 이 필드 값으로 들어간다 */}
|
||||||
|
{session.currentField?.promptText && (
|
||||||
|
<PhosphorText variant="dim" sx={{ mt: 1 }}>
|
||||||
|
{session.currentField.promptText}
|
||||||
|
</PhosphorText>
|
||||||
|
)}
|
||||||
|
</MetalCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 마지막 완성 결과 */}
|
||||||
|
{!session && lastOutput && (
|
||||||
|
<MetalCard sx={{ mb: 2 }}>
|
||||||
|
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{lastOutput}
|
||||||
|
</PhosphorText>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
|
||||||
|
<PhysicalButton size="small" onClick={handleCopyOutput}>
|
||||||
|
{t('common.copy')}
|
||||||
|
</PhysicalButton>
|
||||||
|
<PhysicalButton size="small" onClick={() => setLastOutput(null)}>
|
||||||
|
{t('common.close')}
|
||||||
|
</PhysicalButton>
|
||||||
|
</Box>
|
||||||
</MetalCard>
|
</MetalCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -311,7 +342,7 @@ export function TemplateSection(): React.ReactElement {
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formFields.length === 0 || saving}>
|
<Button onClick={handleSave} variant="contained" disabled={!formName.trim() || formFields.length === 0 || saving}>
|
||||||
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
|
||||||
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