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

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

View file

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

View file

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