From de1e8a82a480ffed357ef399de8c41f436953af9 Mon Sep 17 00:00:00 2001 From: Yun Chan Date: Mon, 28 Sep 2026 00:53:46 +0900 Subject: [PATCH] fix(dictionary): let an edit clear the pronunciation and report duplicate renames --- .../src/main/services/DictionaryService.ts | 36 ++++-- .../src/renderer/pages/DictionaryPage.tsx | 41 ++++--- .../red/dictionary-redteam-r1-12.test.ts | 75 ++++++++++++ .../core/__tests__/dictionary-policy.test.ts | 110 ++++++++++++++++++ packages/core/src/dictionary-policy.ts | 109 +++++++++++++++++ packages/core/src/types.ts | 3 +- 6 files changed, 350 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/tests/red/dictionary-redteam-r1-12.test.ts create mode 100644 packages/core/__tests__/dictionary-policy.test.ts create mode 100644 packages/core/src/dictionary-policy.ts diff --git a/apps/desktop/src/main/services/DictionaryService.ts b/apps/desktop/src/main/services/DictionaryService.ts index 046801f..2fe3409 100644 --- a/apps/desktop/src/main/services/DictionaryService.ts +++ b/apps/desktop/src/main/services/DictionaryService.ts @@ -12,6 +12,12 @@ import { getLogger } from './LoggerService' import { getCloudSyncService } from './CloudSyncService' import { getMainWindow } from '../windows/WindowManager' import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import { + buildDictionaryUpdatePatch, + isUniqueConstraintViolation, + normalizeDictionaryPronunciation, + normalizeDictionaryWord +} from '@d3ro/core/dictionary-policy' import type { DictionaryEntry, DictionaryQueryParams, @@ -114,7 +120,7 @@ function normalizeCategory(value: string | null): DictionaryEntry['category'] { class DictionaryService { add(params: DictionaryAddParams): DictionaryEntry { - const word = params.word.trim() + const word = normalizeDictionaryWord(params.word) if (!word) { throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty') } @@ -126,7 +132,7 @@ class DictionaryService { const entry: NewDictionary = { id, word, - pronunciation: params.pronunciation ?? null, + pronunciation: normalizeDictionaryPronunciation(params.pronunciation) ?? null, category: params.category ?? 'user', usageCount: 0, lastUsedAt: null, @@ -137,8 +143,7 @@ class DictionaryService { try { db.insert(dictionary).values(entry).run() } catch (err) { - const message = err instanceof Error ? err.message : String(err) - if (message.includes('UNIQUE') || message.includes('unique')) { + if (isUniqueConstraintViolation(err)) { throw new D3ROError(ErrorCode.DictionaryDuplicate, `Duplicate word: ${word}`) } throw err @@ -150,16 +155,27 @@ class DictionaryService { } update(params: DictionaryUpdateParams): DictionaryEntry | null { + const result = buildDictionaryUpdatePatch(params) + if (!result.ok) { + throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty') + } + const db = getDatabase() const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get() if (!existing) return null - const updates: Partial = { updatedAt: Date.now() } - if (params.word !== undefined) updates.word = params.word - if (params.pronunciation !== undefined) updates.pronunciation = params.pronunciation - if (params.category !== undefined) updates.category = params.category - - db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run() + const updates: Partial = { ...result.patch, updatedAt: Date.now() } + try { + db.update(dictionary).set(updates).where(eq(dictionary.id, params.id)).run() + } catch (err) { + if (isUniqueConstraintViolation(err)) { + throw new D3ROError( + ErrorCode.DictionaryDuplicate, + `Duplicate word: ${result.patch.word ?? existing.word}` + ) + } + throw err + } const updated = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get() // Phase 3.3: 자동 push (fire-and-forget) diff --git a/apps/desktop/src/renderer/pages/DictionaryPage.tsx b/apps/desktop/src/renderer/pages/DictionaryPage.tsx index d24b366..1ea63cd 100644 --- a/apps/desktop/src/renderer/pages/DictionaryPage.tsx +++ b/apps/desktop/src/renderer/pages/DictionaryPage.tsx @@ -9,6 +9,11 @@ import { PageHeader, SearchInput, EmptyStateCard } from '../components/shared' import { d3roPalette, d3roFontSans, d3roTypo, d3roRadius, d3roShadow } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' import type { DictionaryEntry, DictionaryPage as DictPageData } from '@d3ro/core/types' +import { + dictionarySaveErrorKey, + toDictionaryAddParams, + toDictionaryUpdateParams, +} from '@d3ro/core/dictionary-policy' export function DictionaryPage(): React.ReactElement { const { t } = useI18n() @@ -20,6 +25,7 @@ export function DictionaryPage(): React.ReactElement { const [formWord, setFormWord] = useState('') const [formPronunciation, setFormPronunciation] = useState('') const [saving, setSaving] = useState(false) + const [formError, setFormError] = useState(null) const isSavingRef = useRef(false) const [ioBusy, setIoBusy] = useState(false) const [ioMessage, setIoMessage] = useState(null) @@ -47,6 +53,7 @@ export function DictionaryPage(): React.ReactElement { setEditId(null) setFormWord('') setFormPronunciation('') + setFormError(null) setDialogOpen(true) } @@ -55,6 +62,7 @@ export function DictionaryPage(): React.ReactElement { setEditId(entry.id) setFormWord(entry.word) setFormPronunciation(entry.pronunciation ?? '') + setFormError(null) setDialogOpen(true) } @@ -62,18 +70,16 @@ export function DictionaryPage(): React.ReactElement { if (!formWord.trim() || saving || isSavingRef.current) return isSavingRef.current = true setSaving(true) + setFormError(null) try { - if (editId) { - await window.electronAPI.dictionary.update({ - id: editId, - word: formWord.trim(), - pronunciation: formPronunciation.trim() || undefined, - }) - } else { - await window.electronAPI.dictionary.add({ - word: formWord.trim(), - pronunciation: formPronunciation.trim() || undefined, - }) + const form = { word: formWord, pronunciation: formPronunciation } + const result = editId + ? await window.electronAPI.dictionary.update(toDictionaryUpdateParams(editId, form)) + : await window.electronAPI.dictionary.add(toDictionaryAddParams(form)) + if (!result.success) { + // 다이얼로그를 유지해 사용자가 입력을 고칠 수 있게 한다 (예: 같은 단어로 이름 변경). + setFormError(t(dictionarySaveErrorKey(result.error.code))) + return } setDialogOpen(false) loadData() @@ -308,7 +314,11 @@ export function DictionaryPage(): React.ReactElement { setFormWord(e.target.value)} + onChange={(e) => { + setFormWord(e.target.value) + setFormError(null) + }} + error={formError !== null} fullWidth autoFocus sx={{ mt: 1.5 }} @@ -321,13 +331,18 @@ export function DictionaryPage(): React.ReactElement { sx={{ mt: 2.5 }} helperText="e.g. 디쓰리오" /> + {formError && ( + + {formError} + + )} setDialogOpen(false)}> {t('common.cancel')} - {saving ? t('common.saving') || '저장 중...' : t('common.save')} + {saving ? t('common.saving') : t('common.save')} diff --git a/apps/desktop/tests/red/dictionary-redteam-r1-12.test.ts b/apps/desktop/tests/red/dictionary-redteam-r1-12.test.ts new file mode 100644 index 0000000..4d607a0 --- /dev/null +++ b/apps/desktop/tests/red/dictionary-redteam-r1-12.test.ts @@ -0,0 +1,75 @@ +// tests/red/dictionary-redteam-r1-12.test.ts +// 회귀: 사전 편집에서 발음을 지울 수 없던 문제, 같은 단어로 이름을 바꿀 때 +// DictionaryDuplicate 대신 일반 DB 오류로 조용히 실패하던 문제. + +import { describe, it, expect } from 'vitest' +import { D3ROError, ErrorCode } from '@d3ro/core/errors' +import { IPC_CHANNELS } from '@d3ro/core/ipc-channels' +import { toDictionaryUpdateParams } from '@d3ro/core/dictionary-policy' +import { getDictionaryService } from '../../src/main/services/DictionaryService' +import { registerDictionaryHandlers } from '../../src/main/ipc/dictionary-handlers' +import { invokeIpc, useRedHarness } from './harness' + +useRedHarness() + +describe('사전 편집 회귀 (redteam r1 #12)', () => { + it('편집에서 발음 입력을 비우면 저장된 발음이 지워진다', () => { + const svc = getDictionaryService() + const entry = svc.add({ word: 'GPT', pronunciation: 'gee-pee-tee' }) + + const updated = svc.update(toDictionaryUpdateParams(entry.id, { word: 'GPT', pronunciation: '' })) + + expect(updated?.pronunciation).toBeNull() + const reloaded = svc.list({ page: 0, pageSize: 10 }).entries.find((e) => e.id === entry.id) + expect(reloaded?.pronunciation).toBeNull() + }) + + it('pronunciation: null 은 발음을 지우고, 생략하면 유지한다', () => { + const svc = getDictionaryService() + const entry = svc.add({ word: 'Whisper', pronunciation: '위스퍼' }) + + expect(svc.update({ id: entry.id, word: 'Whisper2' })?.pronunciation).toBe('위스퍼') + expect(svc.update({ id: entry.id, pronunciation: null })?.pronunciation).toBeNull() + }) + + it('공백뿐인 발음은 빈 문자열로 저장하지 않고 지운다', () => { + const svc = getDictionaryService() + const entry = svc.add({ word: 'Blank', pronunciation: '블랭크' }) + + expect(svc.update({ id: entry.id, pronunciation: ' ' })?.pronunciation).toBeNull() + }) + + it('같은 카테고리의 기존 단어로 이름을 바꾸면 DictionaryDuplicate 이다', () => { + const svc = getDictionaryService() + svc.add({ word: 'Ollama' }) + const other = svc.add({ word: 'Llama' }) + + try { + svc.update({ id: other.id, word: 'Ollama' }) + expect.unreachable() + } catch (err) { + expect(err).toBeInstanceOf(D3ROError) + expect((err as D3ROError).code).toBe(ErrorCode.DictionaryDuplicate) + } + }) + + it('IPC update 도 이름 중복을 DictionaryDuplicate 로 돌려준다', async () => { + registerDictionaryHandlers() + const svc = getDictionaryService() + svc.add({ word: 'dup-target' }) + const other = svc.add({ word: 'dup-source' }) + + const res = await invokeIpc(IPC_CHANNELS.DICTIONARY.UPDATE, { id: other.id, word: 'dup-target' }) + + expect(res.success).toBe(false) + if (!res.success) expect(res.error.code).toBe(ErrorCode.DictionaryDuplicate) + }) + + it('편집에서 단어를 비우면 거부하고, 단어는 trim 해 저장한다', () => { + const svc = getDictionaryService() + const entry = svc.add({ word: 'Trim' }) + + expect(() => svc.update({ id: entry.id, word: ' ' })).toThrow(D3ROError) + expect(svc.update({ id: entry.id, word: ' Trimmed ' })?.word).toBe('Trimmed') + }) +}) diff --git a/packages/core/__tests__/dictionary-policy.test.ts b/packages/core/__tests__/dictionary-policy.test.ts new file mode 100644 index 0000000..c0a5597 --- /dev/null +++ b/packages/core/__tests__/dictionary-policy.test.ts @@ -0,0 +1,110 @@ +// packages/core/__tests__/dictionary-policy.test.ts +// 사전 편집 정책 순수 함수 테스트 — 발음 지우기, 단어 검증, UNIQUE 판별, 폼 → 요청 변환. + +import { describe, expect, it } from 'vitest' +import { ErrorCode } from '../src/errors' +import { + buildDictionaryUpdatePatch, + dictionarySaveErrorKey, + isUniqueConstraintViolation, + normalizeDictionaryPronunciation, + normalizeDictionaryWord, + toDictionaryAddParams, + toDictionaryUpdateParams +} from '../src/dictionary-policy' + +describe('normalizeDictionaryWord', () => { + it('앞뒤 공백을 제거한다', () => { + expect(normalizeDictionaryWord(' GPT ')).toBe('GPT') + }) + it('공백뿐이면 null이다', () => { + expect(normalizeDictionaryWord(' ')).toBeNull() + expect(normalizeDictionaryWord('')).toBeNull() + }) +}) + +describe('normalizeDictionaryPronunciation', () => { + it('undefined는 변경 없음으로 유지한다', () => { + expect(normalizeDictionaryPronunciation(undefined)).toBeUndefined() + }) + it('null과 빈 문자열은 지우기(null)다', () => { + expect(normalizeDictionaryPronunciation(null)).toBeNull() + expect(normalizeDictionaryPronunciation('')).toBeNull() + expect(normalizeDictionaryPronunciation(' ')).toBeNull() + }) + it('값은 trim 한다', () => { + expect(normalizeDictionaryPronunciation(' gee-pee-tee ')).toBe('gee-pee-tee') + }) +}) + +describe('buildDictionaryUpdatePatch', () => { + it('발음 null은 패치에 null로 들어간다 (지우기)', () => { + const result = buildDictionaryUpdatePatch({ id: 'a', pronunciation: null }) + expect(result).toEqual({ ok: true, patch: { pronunciation: null } }) + }) + it('빈 발음 문자열도 null로 지운다', () => { + const result = buildDictionaryUpdatePatch({ id: 'a', pronunciation: ' ' }) + expect(result).toEqual({ ok: true, patch: { pronunciation: null } }) + }) + it('지정하지 않은 필드는 패치에 없다', () => { + const result = buildDictionaryUpdatePatch({ id: 'a' }) + expect(result).toEqual({ ok: true, patch: {} }) + }) + it('단어는 trim 하고, 비면 거부한다', () => { + expect(buildDictionaryUpdatePatch({ id: 'a', word: ' GPT ', category: 'technical' })).toEqual({ + ok: true, + patch: { word: 'GPT', category: 'technical' } + }) + expect(buildDictionaryUpdatePatch({ id: 'a', word: ' ' })).toEqual({ + ok: false, + reason: 'empty-word' + }) + }) +}) + +describe('isUniqueConstraintViolation', () => { + it('SQLite UNIQUE 오류를 판별한다', () => { + expect( + isUniqueConstraintViolation( + new Error('UNIQUE constraint failed: dictionary.word, dictionary.category') + ) + ).toBe(true) + expect(isUniqueConstraintViolation('unique violation')).toBe(true) + expect(isUniqueConstraintViolation(new Error('disk I/O error'))).toBe(false) + }) +}) + +describe('폼 → 요청 변환', () => { + it('추가: 빈 발음은 보내지 않는다', () => { + expect(toDictionaryAddParams({ word: ' GPT ', pronunciation: ' ' })).toEqual({ word: 'GPT' }) + expect(toDictionaryAddParams({ word: 'GPT', pronunciation: ' 지피티 ' })).toEqual({ + word: 'GPT', + pronunciation: '지피티' + }) + }) + it('편집: 비운 발음은 null로 보내 지우기를 명시한다', () => { + expect(toDictionaryUpdateParams('id-1', { word: 'GPT', pronunciation: '' })).toEqual({ + id: 'id-1', + word: 'GPT', + pronunciation: null + }) + expect(toDictionaryUpdateParams('id-1', { word: 'GPT', pronunciation: 'x' }).pronunciation).toBe( + 'x' + ) + }) +}) + +describe('dictionarySaveErrorKey', () => { + it('오류 코드를 i18n 키로 바꾼다', () => { + expect(dictionarySaveErrorKey(ErrorCode.DictionaryDuplicate)).toBe( + 'mobile.dictionary.error.duplicate' + ) + expect(dictionarySaveErrorKey(ErrorCode.DictionaryNotFound)).toBe( + 'mobile.dictionary.error.notFound' + ) + expect(dictionarySaveErrorKey(ErrorCode.DictionaryImportInvalidFormat)).toBe( + 'mobile.dictionary.error.validation' + ) + expect(dictionarySaveErrorKey(ErrorCode.DBWriteFailed)).toBe('mobile.dictionary.error.server') + }) +}) diff --git a/packages/core/src/dictionary-policy.ts b/packages/core/src/dictionary-policy.ts new file mode 100644 index 0000000..6cd1a93 --- /dev/null +++ b/packages/core/src/dictionary-policy.ts @@ -0,0 +1,109 @@ +// packages/core/src/dictionary-policy.ts +// 사용자 사전 편집 정책 — 순수 함수만 둔다 (DB/IPC/UI 의존 없음). +// 데스크톱 DictionaryService(쓰기 규칙)와 DictionaryPage(폼 → 요청 변환)가 같은 규칙을 공유한다. + +import { ErrorCode } from './errors' +import type { DictionaryAddParams, DictionaryEntry, DictionaryUpdateParams } from './types' + +export type DictionaryCategory = DictionaryEntry['category'] + +/** 단어를 정규화한다. 공백뿐이면 null (= 유효하지 않음). */ +export function normalizeDictionaryWord(raw: string): string | null { + const word = raw.trim() + return word.length > 0 ? word : null +} + +/** + * 발음 입력을 정규화한다. + * - undefined → undefined: "변경 없음" + * - null 또는 공백뿐인 문자열 → null: "발음 지우기" + * - 그 외 → 앞뒤 공백을 제거한 값 + */ +export function normalizeDictionaryPronunciation( + raw: string | null | undefined +): string | null | undefined { + if (raw === undefined) return undefined + if (raw === null) return null + const pronunciation = raw.trim() + return pronunciation.length > 0 ? pronunciation : null +} + +/** DB에 그대로 쓸 수 있는 부분 갱신 값. 키가 없으면 해당 필드는 바꾸지 않는다. */ +export interface DictionaryUpdatePatch { + word?: string + pronunciation?: string | null + category?: DictionaryCategory +} + +export type DictionaryUpdatePatchResult = + | { ok: true; patch: DictionaryUpdatePatch } + | { ok: false; reason: 'empty-word' } + +/** 갱신 요청을 검증·정규화해 쓰기 패치로 바꾼다. */ +export function buildDictionaryUpdatePatch( + params: DictionaryUpdateParams +): DictionaryUpdatePatchResult { + const patch: DictionaryUpdatePatch = {} + if (params.word !== undefined) { + const word = normalizeDictionaryWord(params.word) + if (word === null) return { ok: false, reason: 'empty-word' } + patch.word = word + } + const pronunciation = normalizeDictionaryPronunciation(params.pronunciation) + if (pronunciation !== undefined) patch.pronunciation = pronunciation + if (params.category !== undefined) patch.category = params.category + return { ok: true, patch } +} + +/** SQLite UNIQUE 제약 위반 여부 (better-sqlite3 오류 메시지 기준). */ +export function isUniqueConstraintViolation(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err) + return /unique/i.test(message) +} + +/** 추가/편집 다이얼로그의 입력값. */ +export interface DictionaryFormValues { + word: string + pronunciation: string +} + +/** 새 단어 추가 요청: 빈 발음은 보내지 않는다. */ +export function toDictionaryAddParams(form: DictionaryFormValues): DictionaryAddParams { + const pronunciation = normalizeDictionaryPronunciation(form.pronunciation) + return { + word: form.word.trim(), + ...(pronunciation ? { pronunciation } : {}) + } +} + +/** 편집 요청: 비운 발음은 null로 보내 "지우기"를 명시한다. */ +export function toDictionaryUpdateParams( + id: string, + form: DictionaryFormValues +): DictionaryUpdateParams { + return { + id, + word: form.word.trim(), + pronunciation: normalizeDictionaryPronunciation(form.pronunciation) ?? null + } +} + +/** 사전 저장 실패를 사용자 메시지 i18n 키로 바꾼다. */ +export type DictionarySaveErrorKey = + | 'mobile.dictionary.error.duplicate' + | 'mobile.dictionary.error.notFound' + | 'mobile.dictionary.error.validation' + | 'mobile.dictionary.error.server' + +export function dictionarySaveErrorKey(code: ErrorCode): DictionarySaveErrorKey { + switch (code) { + case ErrorCode.DictionaryDuplicate: + return 'mobile.dictionary.error.duplicate' + case ErrorCode.DictionaryNotFound: + return 'mobile.dictionary.error.notFound' + case ErrorCode.DictionaryImportInvalidFormat: + return 'mobile.dictionary.error.validation' + default: + return 'mobile.dictionary.error.server' + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 413e5cb..e04861d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -718,7 +718,8 @@ export interface DictionaryAddParams { export interface DictionaryUpdateParams { id: string word?: string - pronunciation?: string + /** undefined = 변경 없음, null(또는 빈 문자열) = 발음 지우기 */ + pronunciation?: string | null category?: 'user' | 'auto' | 'technical' }