fix(dictionary): let an edit clear the pronunciation and report duplicate renames

This commit is contained in:
Yun Chan 2026-09-28 00:53:46 +09:00
parent 9d5d043e8b
commit de1e8a82a4
6 changed files with 350 additions and 24 deletions

View file

@ -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<NewDictionary> = { 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<NewDictionary> = { ...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)

View file

@ -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<string | null>(null)
const isSavingRef = useRef(false)
const [ioBusy, setIoBusy] = useState(false)
const [ioMessage, setIoMessage] = useState<string | null>(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 {
<TextField
label={t('dictionary.word')}
value={formWord}
onChange={(e) => 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 && (
<PhosphorText variant="meta" role="alert" sx={{ display: 'block', mt: 1.5, color: d3roPalette.tag.red }}>
{formError}
</PhosphorText>
)}
</DialogContent>
<DialogActions sx={{ px: 2.5, pb: 2, pt: 1, gap: 1 }}>
<PhysicalButton tone="glass" onClick={() => setDialogOpen(false)}>
{t('common.cancel')}
</PhysicalButton>
<PhysicalButton tone="accent" onClick={handleSave} disabled={!formWord.trim() || saving}>
{saving ? t('common.saving') || '저장 중...' : t('common.save')}
{saving ? t('common.saving') : t('common.save')}
</PhysicalButton>
</DialogActions>
</Dialog>

View file

@ -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')
})
})