generated from ttipo/project-base
fix: 화면 · 오류 문구를 사람이 말하듯 다듬고 엔진 검색의 기간 시작 경계를 고친다 #19
@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { directionParticle, objectParticle, subjectParticle } from './korean'
|
||||
|
||||
// 규칙은 korean.ts 머리말 — 짝: 앱 `flutter/lib/core/utils/korean_text.dart` (한쪽을 고치면 둘 다)
|
||||
|
||||
describe('subjectParticle (주격 조사 이/가)', () => {
|
||||
it.each([
|
||||
['받은편지함', '이'],
|
||||
@@ -27,9 +29,10 @@ describe('directionParticle (방향 조사 으로/로)', () => {
|
||||
// ㄹ 받침 → 로
|
||||
['서울', '로'],
|
||||
['메일', '로'],
|
||||
// 한글로 끝나지 않으면 발음을 알 수 없다
|
||||
// 숫자는 읽는 소리로 — 2026 은 "육"
|
||||
['2026', '으로'],
|
||||
// 라틴 글자로 끝나면 소리를 알 수 없다
|
||||
['Work', '(으)로'],
|
||||
['2026', '(으)로'],
|
||||
['', '(으)로'],
|
||||
])('%s → %s', (word, particle) => {
|
||||
expect(directionParticle(word)).toBe(particle)
|
||||
@@ -49,3 +52,80 @@ describe('objectParticle (목적격 조사 을/를 — "휴지통을 비웠습
|
||||
expect(objectParticle(word)).toBe(particle)
|
||||
})
|
||||
})
|
||||
|
||||
describe('한글 호환 자모 (U+3131~U+3163 — 초성으로 찾은 말)', () => {
|
||||
// 자음은 이름(기역 · 리을 · 미음 …)이 모두 받침으로 끝난다 — ㄹ(리을)만 ㄹ 받침이라 "로". 쌍자음 · 겹자음도 받침 있음(ㄹ 받침이 아니다)
|
||||
it.each([
|
||||
['ㄱ', '기역', '이', '을', '으로'],
|
||||
['ㄹ', '리을', '이', '을', '로'],
|
||||
['ㅁ', '미음', '이', '을', '으로'],
|
||||
['ㅎ', '히읗', '이', '을', '으로'],
|
||||
['ㄲ', '쌍기역', '이', '을', '으로'],
|
||||
['ㄳ', '기역시옷', '이', '을', '으로'],
|
||||
['ㄺ', '리을기역', '이', '을', '으로'],
|
||||
['ㅏ', '아', '가', '를', '로'],
|
||||
['ㅣ', '이', '가', '를', '로'],
|
||||
])('%s(%s) → %s · %s · %s', (jamo, reading, subject, object, direction) => {
|
||||
expect([subjectParticle(jamo), objectParticle(jamo), directionParticle(jamo)]).toEqual([subject, object, direction])
|
||||
// 읽는 소리에 붙인 조사와 같다
|
||||
expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([
|
||||
subject,
|
||||
object,
|
||||
direction,
|
||||
])
|
||||
})
|
||||
|
||||
it('초성 검색어는 마지막 자모로 고른다 — 범위 밖의 옛 자모는 소리를 알 수 없는 꼴', () => {
|
||||
expect(directionParticle('ㄱㅁ')).toBe('으로')
|
||||
expect(directionParticle('박ㅈ')).toBe('으로')
|
||||
expect(subjectParticle('김ㅇㄹ')).toBe('이')
|
||||
expect(directionParticle('김ㅇㄹ')).toBe('로')
|
||||
// ㆍ(아래아 U+318D) — 호환 자모지만 U+3163 밖
|
||||
expect(subjectParticle('ㆍ')).toBe('이(가)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('숫자 0~9 — 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침으로', () => {
|
||||
it.each([
|
||||
['0', '영', '이', '을', '으로'],
|
||||
['1', '일', '이', '을', '로'],
|
||||
['2', '이', '가', '를', '로'],
|
||||
['3', '삼', '이', '을', '으로'],
|
||||
['4', '사', '가', '를', '로'],
|
||||
['5', '오', '가', '를', '로'],
|
||||
['6', '육', '이', '을', '으로'],
|
||||
['7', '칠', '이', '을', '로'],
|
||||
['8', '팔', '이', '을', '로'],
|
||||
['9', '구', '가', '를', '로'],
|
||||
])('%s(%s) → %s · %s · %s', (digit, reading, subject, object, direction) => {
|
||||
expect([subjectParticle(digit), objectParticle(digit), directionParticle(digit)]).toEqual([subject, object, direction])
|
||||
// 읽는 소리에 붙인 조사와 같다
|
||||
expect([subjectParticle(reading), objectParticle(reading), directionParticle(reading)]).toEqual([
|
||||
subject,
|
||||
object,
|
||||
direction,
|
||||
])
|
||||
})
|
||||
|
||||
it('여러 자리 수 · 글자 뒤의 숫자도 마지막 숫자로 본다', () => {
|
||||
expect(subjectParticle('2026')).toBe('이')
|
||||
expect(subjectParticle('프로젝트2')).toBe('가')
|
||||
expect(objectParticle('3분기 보고서 v10')).toBe('을')
|
||||
expect(directionParticle('회의실 7')).toBe('로')
|
||||
})
|
||||
})
|
||||
|
||||
describe('끝의 공백 · 닫는 따옴표 · 괄호 (지금 규칙 그대로)', () => {
|
||||
it('앞뒤 공백은 지우고 본다 — 자모 · 숫자도 같다', () => {
|
||||
expect(directionParticle('스팸메일함 ')).toBe('으로')
|
||||
expect(directionParticle('ㄹ ')).toBe('로')
|
||||
expect(subjectParticle(' 7 ')).toBe('이')
|
||||
})
|
||||
|
||||
it('닫는 따옴표 · 괄호는 건너뛰지 않는다 — 기호로 끝나면 소리를 알 수 없는 꼴 (부르는 쪽이 따옴표 안의 말을 넘긴다)', () => {
|
||||
expect(subjectParticle('보고서’')).toBe('이(가)')
|
||||
expect(subjectParticle('"서버 이전"')).toBe('이(가)')
|
||||
expect(objectParticle('휴지통)')).toBe('을(를)')
|
||||
expect(directionParticle('회의(2)')).toBe('(으)로')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
// 한국어 문구 보조 — 순수 함수만 둔다
|
||||
//
|
||||
// 조사는 앞말의 마지막 글자를 읽는 소리의 받침으로 고른다. 앞뒤 공백은 지우고 보며, 닫는 따옴표 · 괄호 같은 기호는 건너뛰지 않는다
|
||||
// (기호로 끝나면 소리를 알 수 없는 것으로 본다 — 부르는 쪽이 따옴표 안의 말을 넘긴다).
|
||||
// - 한글 음절: 그 글자의 받침
|
||||
// - 한글 호환 자모(U+3131~U+3163 — 초성으로 찾을 때 흔하다): 자음 ㄱ~ㅎ은 이름(기역 · 니은 · 디귿 · 리을 · 미음 …)이 모두
|
||||
// 받침으로 끝나 받침 있음이고, ㄹ(리을)만 ㄹ 받침이다. 쌍자음 · 겹자음(ㄲ · ㄳ · ㄺ …)도 받침 있음(ㄹ 받침이 아니다). 모음 ㅏ~ㅣ는 받침 없음
|
||||
// - 숫자 0~9: 읽는 소리(영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구)의 받침 — 받침 있음 0 · 1 · 3 · 6 · 7 · 8(그 가운데 ㄹ 받침 1 · 7 · 8),
|
||||
// 받침 없음 2 · 4 · 5 · 9. 여러 자리 수도 마지막 숫자로 본다("2026" → 육)
|
||||
// - 그 밖(라틴 글자 · 기호 · 빈 글자)은 소리를 알 수 없어 두 꼴을 함께 쓴다 — "이(가)" · "을(를)" · "(으)로"
|
||||
// 짝: flutter `lib/core/utils/korean_text.dart` — 규칙을 바꾸면 둘 다
|
||||
|
||||
/** 마지막 소리의 받침 — 없음 · ㄹ 받침 · 그 밖의 받침 */
|
||||
type FinalSound = 'none' | 'rieul' | 'other'
|
||||
|
||||
const HANGUL_SYLLABLE_START = 0xac00
|
||||
const HANGUL_SYLLABLE_END = 0xd7a3
|
||||
@@ -7,43 +20,73 @@ const JONGSEONG_COUNT = 28
|
||||
/** 받침 번호 — 0 은 받침 없음, 8 은 ㄹ */
|
||||
const NO_JONGSEONG = 0
|
||||
const JONGSEONG_RIEUL = 8
|
||||
/** 한글 호환 자모 — 자음 ㄱ(U+3131)~ㅎ(U+314E), 그 가운데 ㄹ(U+3139), 모음 ㅏ(U+314F)~ㅣ(U+3163) */
|
||||
const JAMO_CONSONANT_START = 0x3131
|
||||
const JAMO_CONSONANT_END = 0x314e
|
||||
const JAMO_RIEUL = 0x3139
|
||||
const JAMO_VOWEL_START = 0x314f
|
||||
const JAMO_VOWEL_END = 0x3163
|
||||
/** 숫자 0~9 */
|
||||
const DIGIT_ZERO = 0x30
|
||||
const DIGIT_NINE = 0x39
|
||||
/** 숫자 0~9 를 읽는 소리의 받침 — 영 · 일 · 이 · 삼 · 사 · 오 · 육 · 칠 · 팔 · 구 */
|
||||
const DIGIT_FINAL_SOUNDS: readonly FinalSound[] = [
|
||||
'other', // 0 영
|
||||
'rieul', // 1 일
|
||||
'none', // 2 이
|
||||
'other', // 3 삼
|
||||
'none', // 4 사
|
||||
'none', // 5 오
|
||||
'other', // 6 육
|
||||
'rieul', // 7 칠
|
||||
'rieul', // 8 팔
|
||||
'none', // 9 구
|
||||
]
|
||||
|
||||
/** 마지막 글자의 받침 번호 — 한글 음절로 끝나지 않으면(영문 · 숫자 · 빈 글자) null */
|
||||
function lastJongseong(word: string): number | null {
|
||||
/** 마지막 글자를 읽는 소리의 받침 — 소리를 알 수 없으면(라틴 글자 · 기호 · 빈 글자) null */
|
||||
function finalSound(word: string): FinalSound | null {
|
||||
const last = Array.from(word.trim()).pop()
|
||||
if (!last) return null
|
||||
const code = last?.codePointAt(0)
|
||||
if (code === undefined) return null
|
||||
|
||||
const code = last.charCodeAt(0)
|
||||
if (code < HANGUL_SYLLABLE_START || code > HANGUL_SYLLABLE_END) return null
|
||||
return (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
|
||||
if (code >= HANGUL_SYLLABLE_START && code <= HANGUL_SYLLABLE_END) {
|
||||
const jongseong = (code - HANGUL_SYLLABLE_START) % JONGSEONG_COUNT
|
||||
if (jongseong === NO_JONGSEONG) return 'none'
|
||||
return jongseong === JONGSEONG_RIEUL ? 'rieul' : 'other'
|
||||
}
|
||||
if (code >= JAMO_CONSONANT_START && code <= JAMO_CONSONANT_END) return code === JAMO_RIEUL ? 'rieul' : 'other'
|
||||
if (code >= JAMO_VOWEL_START && code <= JAMO_VOWEL_END) return 'none'
|
||||
if (code >= DIGIT_ZERO && code <= DIGIT_NINE) return DIGIT_FINAL_SOUNDS[code - DIGIT_ZERO] ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 주격 조사 "이/가" — 마지막 글자에 받침이 있으면 "이", 없으면 "가".
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "이(가)" 를 쓴다.
|
||||
* 주격 조사 "이/가" — 마지막 소리에 받침이 있으면 "이", 없으면 "가" (휴지통이 · 프로젝트가 · ㄱ이 · 2가).
|
||||
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "이(가)" 를 쓴다.
|
||||
*/
|
||||
export function subjectParticle(word: string): '이' | '가' | '이(가)' {
|
||||
const jongseong = lastJongseong(word)
|
||||
if (jongseong === null) return '이(가)'
|
||||
return jongseong === NO_JONGSEONG ? '가' : '이'
|
||||
const sound = finalSound(word)
|
||||
if (sound === null) return '이(가)'
|
||||
return sound === 'none' ? '가' : '이'
|
||||
}
|
||||
|
||||
/**
|
||||
* 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를).
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "을(를)" 을 쓴다.
|
||||
* 목적격 조사 "을/를" — 받침이 있으면 "을", 없으면 "를" (휴지통을 · 스팸메일함을 · 보관함을 · 메일 서버를 · 3을 · ㅏ를).
|
||||
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "을(를)" 을 쓴다.
|
||||
*/
|
||||
export function objectParticle(word: string): '을' | '를' | '을(를)' {
|
||||
const jongseong = lastJongseong(word)
|
||||
if (jongseong === null) return '을(를)'
|
||||
return jongseong === NO_JONGSEONG ? '를' : '을'
|
||||
const sound = finalSound(word)
|
||||
if (sound === null) return '을(를)'
|
||||
return sound === 'none' ? '를' : '을'
|
||||
}
|
||||
|
||||
/**
|
||||
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로" (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로).
|
||||
* 한글로 끝나지 않는 이름(영문·숫자)은 발음을 알 수 없어 "(으)로" 를 쓴다.
|
||||
* 방향 조사 "으로/로" — 받침이 있으면 "으로", 받침이 없거나 ㄹ 받침이면 "로"
|
||||
* (보낸편지함으로 · 휴지통으로 · 보관함으로 · 서울로 · ㄱ으로 · ㄹ로 · 6으로 · 7로).
|
||||
* 소리를 알 수 없는 이름(라틴 글자 · 기호로 끝남)은 "(으)로" 를 쓴다.
|
||||
*/
|
||||
export function directionParticle(word: string): '으로' | '로' | '(으)로' {
|
||||
const jongseong = lastJongseong(word)
|
||||
if (jongseong === null) return '(으)로'
|
||||
return jongseong === NO_JONGSEONG || jongseong === JONGSEONG_RIEUL ? '로' : '으로'
|
||||
const sound = finalSound(word)
|
||||
if (sound === null) return '(으)로'
|
||||
return sound === 'other' ? '으로' : '로'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user