refactor(i18n): 코드포인트 기반 문자열 분할을 공통화한다

This commit is contained in:
2026-11-10 10:23:19 +09:00
parent 2bd2328548
commit a8e4cbac28
4 changed files with 208 additions and 11 deletions
@@ -0,0 +1,41 @@
package kr.co.vividnext.sodalive.i18n.translation
internal fun splitTextByCodePoint(text: String, maxSize: Int, codePointSize: (Int) -> Int): List<String> {
val chunks = mutableListOf<String>()
var start = 0
do {
val end = findChunkEnd(text, start, maxSize, codePointSize)
chunks.add(text.substring(start, end))
start = end
} while (start < text.length)
return chunks
}
internal fun takeTextPrefixByCodePoint(text: String, maxSize: Int, codePointSize: (Int) -> Int): String {
return text.substring(0, findChunkEnd(text, 0, maxSize, codePointSize))
}
internal fun utf8CodePointSize(codePoint: Int): Int {
return when {
codePoint <= 0x7F -> 1
codePoint <= 0x7FF -> 2
codePoint in 0xD800..0xDFFF -> 1
codePoint <= 0xFFFF -> 3
else -> 4
}
}
private fun findChunkEnd(text: String, start: Int, maxSize: Int, codePointSize: (Int) -> Int): Int {
var end = start
var size = 0
while (end < text.length) {
val codePoint = text.codePointAt(end)
val nextSize = codePointSize(codePoint)
if (size + nextSize > maxSize) {
break
}
size += nextSize
end += Character.charCount(codePoint)
}
return end
}
@@ -78,17 +78,7 @@ class PapagoTranslationService(
}
private fun splitText(text: String): List<String> {
val chunks = mutableListOf<String>()
var start = 0
do {
var end = minOf(start + MAX_REQUEST_TEXT_LENGTH, text.length)
if (end < text.length && text[end - 1].isHighSurrogate() && text[end].isLowSurrogate()) {
end -= 1
}
chunks.add(text.substring(start, end))
start = end
} while (start < text.length)
return chunks
return splitTextByCodePoint(text, MAX_REQUEST_TEXT_LENGTH, Character::charCount)
}
private fun validateLanguages(sourceLanguage: String, targetLanguage: String): Boolean {
@@ -0,0 +1,157 @@
package kr.co.vividnext.sodalive.i18n.translation
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.ValueSource
class CodePointTextSplitterTest {
@Test
@DisplayName("정확히 5000 UTF-16 단위인 한글과 이모지는 한 조각으로 유지한다")
fun shouldKeepTextAtUtf16LimitInOneChunk() {
val source = "가".repeat(4998) + "\uD83D\uDE00"
assertEquals(listOf(source), splitTextByCodePoint(source, 5000, Character::charCount))
}
@Test
@DisplayName("5001 UTF-16 단위 원문은 5000 단위와 나머지로 분할한다")
fun shouldSplitTextAboveUtf16Limit() {
val first = "a".repeat(5000)
assertEquals(listOf(first, "b"), splitTextByCodePoint(first + "b", 5000, Character::charCount))
}
@Test
@DisplayName("UTF-16 경계에 걸친 서로게이트 쌍을 다음 조각에 함께 둔다")
fun shouldKeepSurrogatePairTogetherAtUtf16Boundary() {
val first = "a".repeat(4999)
val second = "\uD83D\uDE00b"
assertEquals(listOf(first, second), splitTextByCodePoint(first + second, 5000, Character::charCount))
}
@Test
@DisplayName("정확히 10000 UTF-8 바이트인 한글과 이모지는 한 조각으로 유지한다")
fun shouldKeepTextAtUtf8LimitInOneChunk() {
val source = "가".repeat(3332) + "\uD83D\uDE00"
assertEquals(10000, source.toByteArray(Charsets.UTF_8).size)
assertEquals(listOf(source), splitTextByCodePoint(source, 10000, ::utf8CodePointSize))
}
@Test
@DisplayName("10001 UTF-8 바이트 원문은 10000 바이트와 나머지로 분할한다")
fun shouldSplitTextAboveUtf8Limit() {
val first = "a".repeat(10000)
assertEquals(listOf(first, "b"), splitTextByCodePoint(first + "b", 10000, ::utf8CodePointSize))
}
@ParameterizedTest
@ValueSource(strings = ["가", "\uD83D\uDE00"])
@DisplayName("UTF-8 경계의 한글과 이모지를 자르거나 한도를 초과하지 않는다")
fun shouldKeepWholeCodePointAtUtf8Boundary(boundaryText: String) {
val first = "a".repeat(9998)
val second = boundaryText + "b"
assertEquals(listOf(first, second), splitTextByCodePoint(first + second, 10000, ::utf8CodePointSize))
}
@Test
@DisplayName("UTF-16 분할 후 한글과 이모지 및 모든 공백을 원문 그대로 재구성한다")
fun shouldReconstructOriginalTextAfterUtf16Splitting() {
val source = " \t가\uD83D\uDE00\r\n".repeat(3000) + " "
val chunks = splitTextByCodePoint(source, 5000, Character::charCount)
assertEquals(source, chunks.joinToString(""))
assertTrue(chunks.all { it.length in 1..5000 })
chunks.forEach { assertEquals(it, String(it.toByteArray(Charsets.UTF_8), Charsets.UTF_8)) }
}
@Test
@DisplayName("UTF-8 분할 후 한글과 이모지 및 모든 공백을 원문 그대로 재구성한다")
fun shouldReconstructOriginalTextAfterUtf8Splitting() {
val source = " \t가\uD83D\uDE00\r\n".repeat(3000) + " "
val chunks = splitTextByCodePoint(source, 10000, ::utf8CodePointSize)
assertEquals(source, chunks.joinToString(""))
assertTrue(chunks.all { it.toByteArray(Charsets.UTF_8).size in 1..10000 })
chunks.forEach { assertEquals(it, String(it.toByteArray(Charsets.UTF_8), Charsets.UTF_8)) }
}
@Test
@DisplayName("빈 raw 입력은 빈 문자열 하나를 가진 조각 목록을 반환한다")
fun shouldPreserveEmptyRawInputAsOneChunk() {
assertEquals(listOf(""), splitTextByCodePoint("", 5000, Character::charCount))
assertEquals(listOf(""), splitTextByCodePoint("", 10000, ::utf8CodePointSize))
}
@Test
@DisplayName("감지 prefix는 정확히 100000 UTF-8 바이트를 보존하고 tail을 제외한다")
fun shouldTakeExactUtf8PrefixWithoutTail() {
val prefix = "가".repeat(33332) + "\uD83D\uDE00"
val source = prefix + "excluded-tail"
val result = takeTextPrefixByCodePoint(source, 100000, ::utf8CodePointSize)
assertEquals(prefix, result)
assertEquals(100000, result.toByteArray(Charsets.UTF_8).size)
}
@ParameterizedTest
@ValueSource(strings = ["가", "\uD83D\uDE00"])
@DisplayName("감지 prefix 경계의 코드포인트가 넘치면 남은 바이트를 채우지 않고 멈춘다")
fun shouldStopPrefixBeforeOverflowingCodePoint(boundaryText: String) {
val prefix = "a".repeat(99999)
assertEquals(prefix, takeTextPrefixByCodePoint(prefix + boundaryText + "tail", 100000, ::utf8CodePointSize))
}
@ParameterizedTest
@ValueSource(strings = ["", " \t\n", "한글\uD83D\uDE00"])
@DisplayName("한도보다 짧은 prefix는 빈 문자열과 공백도 변경하지 않는다")
fun shouldPreserveTextWithinPrefixLimit(source: String) {
assertEquals(source, takeTextPrefixByCodePoint(source, 100000, ::utf8CodePointSize))
}
@Test
@DisplayName("prefix는 첫 조각의 경계만 검사하고 나머지 원문을 순회하지 않는다")
fun shouldStopScanningAfterFirstPrefixBoundary() {
val prefix = "a".repeat(100000)
val source = prefix + "가" + "tail".repeat(25000)
var measuredCodePoints = 0
val result = takeTextPrefixByCodePoint(source, 100000) { codePoint ->
measuredCodePoints += 1
utf8CodePointSize(codePoint)
}
assertEquals(prefix, result)
assertEquals(100001, measuredCodePoints)
}
@ParameterizedTest
@ValueSource(
ints = [0, 0x7F, 0x80, 0x7FF, 0x800, 0xD7FF, 0xD800, 0xDBFF, 0xDC00, 0xDFFF, 0xE000, 0xFFFF, 0x10000, 0x1F600, 0x10FFFF]
)
@DisplayName("UTF-8 코드포인트 크기는 JVM 인코딩의 바이트 수와 같다")
fun shouldMatchUtf8EncodedCodePointSize(codePoint: Int) {
val text = String(Character.toChars(codePoint))
assertEquals(text.toByteArray(Charsets.UTF_8).size, utf8CodePointSize(codePoint))
}
@Test
@DisplayName("짝 없는 surrogate도 JVM UTF-8 인코딩 크기로 계산하며 원문을 보존한다")
fun shouldPreserveUnpairedSurrogateWithItsEncodedSize() {
val first = "a".repeat(9999) + "\uD800"
assertEquals(10000, first.toByteArray(Charsets.UTF_8).size)
assertEquals(listOf(first, "b"), splitTextByCodePoint(first + "b", 10000, ::utf8CodePointSize))
}
}
@@ -61,6 +61,15 @@ class PapagoTranslationServiceTest @Autowired constructor(
assertEquals(listOf("translated"), translate(source).translatedText)
}
@ParameterizedTest
@ValueSource(strings = ["", " \t\n"])
@DisplayName("빈 raw 문자열과 공백 원문도 변경 없이 한 번 요청한다")
fun shouldPreserveEmptyAndWhitespaceRawText(source: String) {
expectTranslation(source, "translated")
assertEquals(listOf("translated"), translate(source).translatedText)
}
@Test
@DisplayName("5,001자 원문은 두 번 요청하고 번역을 구분자 없이 결합한다")
fun shouldSplit5001UnitsAndJoinTranslationsWithoutSeparator() {