import { describe, it, expect } from 'vitest' import type { VoiceCommandRule } from '@d3ro/core/types' import { DEFAULT_VOICE_COMMAND_KEYWORDS, buildDefaultRules, findRuleMatch, matchKeyword, migrateLegacyDefaultRules } from '../../../src/main/services/voice-command-policy' import { createVoiceCommandService, type VoiceCommandStorePort } from '../../../src/main/services/VoiceCommandService' function memoryStore( initial: { rules?: VoiceCommandRule[]; enabled?: boolean } = {} ): VoiceCommandStorePort & { saved: VoiceCommandRule[] | undefined } { const state = { rules: initial.rules, enabled: initial.enabled, saved: undefined as VoiceCommandRule[] | undefined } return { get saved() { return state.saved }, loadRules: () => state.rules, saveRules: (rules) => { state.rules = rules state.saved = rules }, loadEnabled: () => state.enabled, saveEnabled: (enabled) => { state.enabled = enabled } } } let seq = 0 const defaultRules = (): VoiceCommandRule[] => buildDefaultRules(() => `r${seq++}`) describe('기본 키워드: 일반 받아쓰기 문장을 명령으로 삼키지 않는다', () => { const dictation = [ '요약 보고서 첨부해서 보내드립니다.', '요약.', '설명 드리겠습니다, 이번 배포는 늦어집니다.', '번역 작업은 다음 주에 끝납니다.', '영어로 된 문서를 보내 주세요.', '다듬기 작업이 남았습니다.', 'Explain to the team that the deploy is delayed', 'Polish the slides before the meeting', 'Translate team is out today', 'Summarize: nothing yet' ] for (const text of dictation) { it(`"${text}" 는 매칭되지 않는다`, () => { expect(findRuleMatch(text, defaultRules())).toBeNull() }) } }) describe('기본 키워드: 명령형은 매칭하고 키워드를 떼어낸다', () => { const cases: Array<[string, string, string]> = [ ['번역해줘 이 문장', 'builtin-translate', '이 문장'], ['번역해 줘, 오늘 회의 끝', 'builtin-translate', '오늘 회의 끝'], ['영어로 번역해줘. 안녕하세요', 'builtin-translate', '안녕하세요'], ['Translate this: good morning', 'builtin-translate', 'good morning'], ['요약해줘 긴 글', 'builtin-summarize', '긴 글'], ['요약해 주세요. 오늘 회의 내용', 'builtin-summarize', '오늘 회의 내용'], ['다듬어줘 메일 초안', 'builtin-formal', '메일 초안'], ['설명해줘 const x = 1', 'builtin-explain-code', 'const x = 1'], ['explain this "for (;;)"', 'builtin-explain-code', '"for (;;)"'] ] for (const [text, instructionId, cleaned] of cases) { it(`"${text}" → ${instructionId}`, () => { const found = findRuleMatch(text, defaultRules()) expect(found?.rule.instructionId).toBe(instructionId) expect(found?.cleanedText).toBe(cleaned) }) } it('명령어만 말하고 내용이 없으면 명령으로 보지 않는다', () => { expect(findRuleMatch('요약해줘.', defaultRules())).toBeNull() expect(findRuleMatch('번역해줘', defaultRules())).toBeNull() }) it('기본 키워드에 맨 명사가 없다', () => { const all = DEFAULT_VOICE_COMMAND_KEYWORDS.flatMap((e) => e.keywords.map((k) => k.keyword)) for (const bare of ['요약', '설명', '번역', '영어로', '다듬기', 'explain', 'polish', 'translate', 'summarize']) { expect(all).not.toContain(bare) } }) }) describe('matchKeyword: 잘라낸 자리의 구두점과 빈 내용', () => { const table: Array<[string, string, 'prefix' | 'suffix' | 'contains', boolean, string]> = [ ['짧게, 오늘 회의 내용', '짧게', 'prefix', true, '오늘 회의 내용'], ['짧게.', '짧게', 'prefix', false, '짧게.'], ['짧게 ...', '짧게', 'prefix', false, '짧게 ...'], ['짧게요 오늘', '짧게', 'prefix', false, '짧게요 오늘'], ['오늘 회의 내용, 번역해줘', '번역해줘', 'suffix', true, '오늘 회의 내용'], ['번역해줘', '번역해줘', 'suffix', false, '번역해줘'], ['이 메일 정중하게 써줘', '정중하게', 'contains', true, '이 메일 써줘'], ['정중하게', '정중하게', 'contains', false, '정중하게'], ['anything', '', 'prefix', false, 'anything'] ] for (const [text, keyword, mode, matched, cleaned] of table) { it(`${mode} "${keyword}" in "${text}"`, () => { expect(matchKeyword(text, keyword, mode)).toEqual({ matched, cleanedText: cleaned }) }) } }) describe('레거시 기본값 이관', () => { const legacy = (): VoiceCommandRule[] => [ { id: 'a', instructionId: 'builtin-summarize', keywords: [ { keyword: '요약해줘', matchMode: 'prefix' }, { keyword: '요약', matchMode: 'prefix' }, { keyword: 'summarize', matchMode: 'prefix' } ], enabled: true, priority: 1 }, { id: 'b', instructionId: 'builtin-translate', keywords: [{ keyword: '영작', matchMode: 'prefix' }], enabled: true, priority: 0 } ] it('손대지 않은 레거시 기본값만 교체하고 사용자 키워드는 둔다', () => { const { rules, migrated } = migrateLegacyDefaultRules(legacy()) expect(migrated).toBe(1) expect(rules[0].id).toBe('a') expect(rules[0].keywords.map((k) => k.keyword)).not.toContain('요약') expect(rules[1].keywords).toEqual([{ keyword: '영작', matchMode: 'prefix' }]) expect(migrateLegacyDefaultRules(rules).migrated).toBe(0) }) it('서비스 초기화 때 저장된 레거시 기본값을 이관하고 저장한다', () => { const store = memoryStore({ rules: legacy(), enabled: true }) const svc = createVoiceCommandService(store) svc.initialize() svc.initDefaultKeywords() expect(svc.match('요약 보고서 첨부해서 보내드립니다.').matched).toBe(false) const cmd = svc.match('요약해줘 긴 글') expect(cmd.instructionId).toBe('builtin-summarize') expect(cmd.cleanedText).toBe('긴 글') expect(store.saved?.find((r) => r.id === 'a')?.keywords.map((k) => k.keyword)).not.toContain( '요약' ) }) it('이관할 것이 없으면 저장하지 않는다', () => { const store = memoryStore({ rules: defaultRules(), enabled: true }) createVoiceCommandService(store).initialize() expect(store.saved).toBeUndefined() }) })