From 230ef0e33817abec5ce5b4bf93decac79b74c394 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 20:02:59 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(mindrecord):=20=EB=8D=B0=EC=9D=BC?= =?UTF-8?q?=EB=A6=AC=EC=A7=88=EB=AC=B8=20=EC=9D=B4=EC=96=B4=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=EA=B0=80=20=EC=9E=84=EC=8B=9C=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B3=B8=EB=AC=B8=EC=9D=84=20=EC=A7=80=EC=9A=B0=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이어쓰기로 재진입하면 에디터가 비어 있고, 그대로 저장하면 기존 임시저장이 빈 값으로 덮였다. 되돌릴 수 없는 유실이다. 원인이 두 겹이었다. 1. **빈 에디터가 `

` 를 내보낸다.** `resumeDraft()` 가 `if (it.answer.isBlank())` 로 "화면이 비었는지" 를 판정하는데, 이 값은 공백이 아니라서 draft 본문이 실리지 않았다. 태그를 걷어 낸 본문으로 판단하도록 바꾼다. 2. **에디터가 외부 값을 초기 시드로만 받는다.** 프리필은 비동기라 에디터가 만들어진 뒤에 도착하므로 화면에 닿지 않았다. 일기 화면이 쓰는 `key(draftLoaded)` 재생성을 같이 적용한다. 불러오는 동안에는 저장을 막는다 — 프리필이 도착하기 전에 저장하면 같은 유실이 난다. Closes #923 Co-Authored-By: Claude Opus 5 (1M context) --- .../mindrecord/presentation/build.gradle.kts | 5 + .../screen/sender/DailyQuestionWriteScreen.kt | 19 ++- .../viewmodel/DailyQuestionWriteUiState.kt | 9 ++ .../viewmodel/DailyQuestionWriteViewModel.kt | 15 +- .../viewmodel/DailyQuestionResumeDraftTest.kt | 135 ++++++++++++++++++ 5 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 76d0c5e121..99bbfa233b 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -6,6 +6,8 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.presentation" + + testOptions.unitTests.isIncludeAndroidResources = true resourcePrefix = "mindrecord_" } @@ -19,4 +21,7 @@ dependencies { implementation(libs.compose.rich.editor) testImplementation(libs.coroutines.test) + + // 이어쓰기 판정이 HtmlCompat 를 타 JVM 단위 테스트로는 돌지 않는다 (#923). + testImplementation(libs.robolectric) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt index 8044dc21e5..0cc12a68cf 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/screen/sender/DailyQuestionWriteScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState @@ -117,13 +118,17 @@ fun DailyQuestionWriteScreen( } Spacer(modifier = Modifier.height(16.dp)) - WriteTextField( - value = uiState.answer, - onValueChange = viewModel::onAnswerChanged, - onSaveDraftClick = { viewModel.submit(isDraft = true) }, - onDraftCountClick = onDraftListClick, - onImagePicked = viewModel::uploadImage, - ) + // draft 프리필은 비동기 완료 — WriteTextField 는 value 를 초기 시드로만 받으므로 + // draftLoaded 전환 시 에디터를 재생성해 본문을 다시 싣는다. 일기 화면과 같다 (#923). + key(uiState.draftLoaded) { + WriteTextField( + value = uiState.answer, + onValueChange = viewModel::onAnswerChanged, + onSaveDraftClick = { viewModel.submit(isDraft = true) }, + onDraftCountClick = onDraftListClick, + onImagePicked = viewModel::uploadImage, + ) + } } } } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index 5e5462c85f..1b08a9d117 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt @@ -9,10 +9,19 @@ data class DailyQuestionWriteUiState( val questionContent: String = "", /** 오늘 이미 임시저장된 답변 레코드 ID — null 이 아니면 제출 시 POST 대신 PATCH 로 전환한다. */ val draftId: Long? = null, + /** + * 이어쓸 임시저장 본문이 도착했는지. + * + * 에디터가 외부 값을 초기 시드로만 받으므로, 화면이 이 플래그로 에디터를 재생성해 본문을 + * 다시 싣는다. 일기 화면의 `draftLoaded` 와 같은 역할이다 (#923). + */ + val draftLoaded: Boolean = false, val answer: String = "", val imageUrl: String? = null, val isQuestionLoading: Boolean = true, val questionLoadError: UiText? = null, + /** 이어쓸 임시저장 본문을 불러오는 중 (#923). */ + val isResumingDraft: Boolean = false, val submitState: SubmitState = SubmitState.Idle, ) { /** diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index c9605a386f..a0a1ad9e28 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -9,6 +9,7 @@ import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.util.htmlToPlainText import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -80,17 +81,27 @@ class DailyQuestionWriteViewModel * (없으면 재제출이 POST 로 나가 이어쓰기가 안 된다). */ private suspend fun resumeDraft() { + _uiState.update { it.copy(isResumingDraft = true) } val draft = repository .getList(date = LocalDate.now().toString(), draftOnly = true) .getOrNull() ?.firstOrNull { it.isDraft } - ?: return + ?: run { + _uiState.update { it.copy(isResumingDraft = false) } + return + } _uiState.update { it.copy( draftId = draft.dailyQuestionId, - answer = if (it.answer.isBlank()) draft.content else it.answer, + // 빈 에디터는 `

` 를 내보내므로 `isBlank()` 로는 "비어 있음" 을 판정할 수 + // 없다. 태그를 걷어 낸 본문으로 판단해야 사용자가 아무것도 안 쓴 상태에서 + // 이어쓸 내용이 실린다 (#923). + answer = if (it.answer.htmlToPlainText().isBlank()) draft.content else it.answer, imageUrl = it.imageUrl ?: draft.imageUrl, + isResumingDraft = false, + // 화면이 이 전환을 보고 에디터를 재생성해 본문을 다시 싣는다 (#923). + draftLoaded = true, ) } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt new file mode 100644 index 0000000000..db33e56093 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -0,0 +1,135 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * 이어쓰기가 임시저장 본문을 싣는지 고정한다 (#923). + * + * 빈 에디터는 `

` 를 내보낸다. `isBlank()` 로 "화면이 비었는지" 를 판정하면 이 값이 + * 비어 있지 않다고 나와 draft 본문이 실리지 않고, 그대로 저장하면 기존 내용이 빈 값으로 + * 덮인다 — 되돌릴 수 없는 유실이다. + * + * `htmlToPlainText()` 가 Android `HtmlCompat` 를 타므로 Robolectric 으로 돌린다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35]) +class DailyQuestionResumeDraftTest { + @Before + fun setUp() { + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `에디터가 빈 HTML 만 들고 있으면 임시저장 본문을 싣는다`() { + val viewModel = viewModel(currentAnswerFromEditor = "

") + + val state = viewModel.uiState.value + assertEquals("

이어쓸 본문

", state.answer) + assertEquals(7L, state.draftId) + assertTrue(state.draftLoaded) + } + + @Test + fun `사용자가 이미 쓴 내용이 있으면 덮지 않는다`() { + val viewModel = viewModel(currentAnswerFromEditor = "

사용자가 방금 쓴 것

") + + assertEquals("

사용자가 방금 쓴 것

", viewModel.uiState.value.answer) + } + + @Test + fun `이어쓸 본문을 불러오는 동안에는 저장할 수 없다`() { + val state = DailyQuestionWriteUiState(answer = "무언가", isResumingDraft = true) + + assertEquals(false, state.canSubmit) + } + + private fun viewModel(currentAnswerFromEditor: String): DailyQuestionWriteViewModel { + val repository = + FakeResumeRepository( + today = + TodayDailyQuestion( + questionId = 1L, + day = 16, + content = "질문", + isAnswered = false, + isDraft = true, + ), + draft = + DailyQuestion( + dailyQuestionId = 7L, + title = "질문", + content = "

이어쓸 본문

", + createdAt = "2026.08.23 일", + isDraft = true, + ), + ) + val viewModel = + DailyQuestionWriteViewModel( + repository = repository, + photoUploadRepository = PhotoUploadRepository { _, _ -> Result.success("") }, + ) + // 에디터가 컴포지션 직후 현재 HTML 을 되돌려 주는 것을 재현한다. + viewModel.onAnswerChanged(currentAnswerFromEditor) + repository.releaseDraft() + return viewModel + } +} + +/** + * 실제 순서를 재현한다 — 화면이 먼저 뜨고 에디터가 현재 HTML 을 되돌려 준 **뒤에** today + * 응답이 도착해 `resumeDraft()` 가 돈다. 그래서 `getToday()` 를 붙잡아 둔다. + */ +private class FakeResumeRepository( + private val today: TodayDailyQuestion, + private val draft: DailyQuestion, +) : DailyQuestionRepository { + private val todayArrived = CompletableDeferred() + + fun releaseDraft() { + todayArrived.complete(Unit) + } + + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> = Result.success(listOf(draft)) + + override suspend fun getToday(): Result { + todayArrived.await() + return Result.success(today) + } + + override suspend fun create(payload: DailyQuestionCreatePayload): Result = Result.success(Unit) + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result = Result.success(Unit) + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +} From 22d12e02cb23af461b971189c587c52413786426 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Sun, 23 Aug 2026 20:05:35 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor(mindrecord):=20=EB=B9=88=20HTML=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20Android=20=EC=9D=98=EC=A1=B4=20?= =?UTF-8?q?=EC=97=86=EB=8A=94=20=EC=88=9C=EC=88=98=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EB=BA=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `htmlToPlainText()` 는 `HtmlCompat` 를 타서, 이 판정을 ViewModel 에 넣자 기존 순수 JUnit 테스트 3건이 깨졌다(스텁 android.jar 에서 빈 값이 돌아온다). 판정은 "태그를 걷어 내면 보이는 글자가 있는가" 뿐이라 Android 가 필요 없다. `isHtmlBlank()` 로 빼서 ViewModel 이 JVM 에서 그대로 돌게 한다 — Robolectric 의존도 되돌린다. Co-Authored-By: Claude Opus 5 (1M context) --- .../mindrecord/presentation/build.gradle.kts | 5 ----- .../mindrecord/presentation/util/HtmlText.kt | 18 ++++++++++++++++++ .../viewmodel/DailyQuestionWriteViewModel.kt | 4 ++-- .../viewmodel/DailyQuestionResumeDraftTest.kt | 7 +------ 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 99bbfa233b..76d0c5e121 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -6,8 +6,6 @@ plugins { android { namespace = "com.afternote.feature.mindrecord.presentation" - - testOptions.unitTests.isIncludeAndroidResources = true resourcePrefix = "mindrecord_" } @@ -21,7 +19,4 @@ dependencies { implementation(libs.compose.rich.editor) testImplementation(libs.coroutines.test) - - // 이어쓰기 판정이 HtmlCompat 를 타 JVM 단위 테스트로는 돌지 않는다 (#923). - testImplementation(libs.robolectric) } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index f4a1d33560..ff89d6d529 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt @@ -14,3 +14,21 @@ fun String.htmlToPlainText(): String = .fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT) .toString() .trim() + +/** + * 태그만 있고 보이는 글자가 없으면 비었다고 본다. + * + * 리치 에디터는 아무것도 입력하지 않아도 `

` 를 내보내므로 `isBlank()` 로는 "화면이 + * 비었는지" 를 판정할 수 없다. 그 값을 "사용자가 이미 썼다" 로 오해하면 이어쓸 임시저장 + * 본문이 실리지 않고, 그대로 저장할 때 기존 내용이 덮인다 (#923). + * + * [htmlToPlainText] 와 달리 Android 에 의존하지 않는다 — ViewModel 판정에 쓰이므로 순수 + * JVM 단위 테스트로 돌아야 한다. + */ +fun String.isHtmlBlank(): Boolean = + replace(HTML_TAG, "") + .replace(HTML_ENTITY) { match -> if (match.value == " ") " " else "" } + .isBlank() + +private val HTML_TAG = Regex("<[^>]*>") +private val HTML_ENTITY = Regex("&[a-zA-Z]+;|&#\\d+;") diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index a0a1ad9e28..e490c99b7d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -9,7 +9,7 @@ import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.presentation.R -import com.afternote.feature.mindrecord.presentation.util.htmlToPlainText +import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -97,7 +97,7 @@ class DailyQuestionWriteViewModel // 빈 에디터는 `

` 를 내보내므로 `isBlank()` 로는 "비어 있음" 을 판정할 수 // 없다. 태그를 걷어 낸 본문으로 판단해야 사용자가 아무것도 안 쓴 상태에서 // 이어쓸 내용이 실린다 (#923). - answer = if (it.answer.htmlToPlainText().isBlank()) draft.content else it.answer, + answer = if (it.answer.isHtmlBlank()) draft.content else it.answer, imageUrl = it.imageUrl ?: draft.imageUrl, isResumingDraft = false, // 화면이 이 전환을 보고 에디터를 재생성해 본문을 다시 싣는다 (#923). diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt index db33e56093..19138a41b5 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -17,9 +17,6 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config /** * 이어쓰기가 임시저장 본문을 싣는지 고정한다 (#923). @@ -28,11 +25,9 @@ import org.robolectric.annotation.Config * 비어 있지 않다고 나와 draft 본문이 실리지 않고, 그대로 저장하면 기존 내용이 빈 값으로 * 덮인다 — 되돌릴 수 없는 유실이다. * - * `htmlToPlainText()` 가 Android `HtmlCompat` 를 타므로 Robolectric 으로 돌린다. + * 판정은 Android 에 의존하지 않는 `isHtmlBlank()` 가 맡으므로 순수 JVM 에서 돈다. */ @OptIn(ExperimentalCoroutinesApi::class) -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [35]) class DailyQuestionResumeDraftTest { @Before fun setUp() { From 2cdadedec2849a50b7559c2be7640b6f40f40539 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 17:10:41 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix(mindrecord):=20=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=EC=A4=91=20=EC=A0=80=EC=9E=A5=20=EC=B0=A8?= =?UTF-8?q?=EB=8B=A8=EC=9D=84=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=EB=B0=B0?= =?UTF-8?q?=EC=84=A0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isResumingDraft 가 쓰기 전용이라 «불러오는 동안 저장 차단» 이 구현돼 있지 않았다. loadTodayQuestion 이 isQuestionLoading=false 를 먼저 커밋한 뒤 resumeDraft 가 돌아, getList 왕복 동안 저장이 열려 있었다. 그 창에서 저장하면 draftId 가 null 이라 POST 로 나가고 서버가 questionId upsert 라 기존 draft 본문이 덮인다. canSubmit 에 !isResumingDraft 를 넣고, canSubmit 을 우회하는 툴바 임시저장을 위해 submit() 초입에도 가드를 둔다. 그 가드를 고정한다던 테스트는 지정하지 않은 isQuestionLoading 기본값 덕에 통과하던 공허한 단언이라 실기 순서대로 명시한다. isHtmlBlank 가 이미지만 있는 본문을 «비었다» 로 판정하던 것도 고친다. 태그를 통째로 걷으면 태그 자체가 내용인 본문이 빈 것으로 접혀, 사진만 첨부한 상태가 도착한 draft 로 덮인다 — 구 isBlank() 가 보호하던 입력이다. 가시 문자 엔티티도 같은 방향으로 뒤집혀 있었다. draftLoaded 는 draft 를 실제로 적용했을 때만 뒤집는다 — 사용자가 이미 쓴 내용을 보존한 경우까지 에디터를 재생성하면 커서·포커스·IME 조합만 리셋되고 진행 중이던 업로드가 끊길 수 있다. 리뷰 지적. Co-Authored-By: Claude Opus 5 (1M context) --- .../mindrecord/presentation/util/HtmlText.kt | 19 +++- .../viewmodel/DailyQuestionWriteUiState.kt | 8 +- .../viewmodel/DailyQuestionWriteViewModel.kt | 18 +++- .../presentation/util/HtmlBlankTest.kt | 58 +++++++++++ .../viewmodel/DailyQuestionResumeDraftTest.kt | 97 +++++++++++++++---- 5 files changed, 170 insertions(+), 30 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlBlankTest.kt diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt index ff89d6d529..1d318b361a 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlText.kt @@ -25,10 +25,23 @@ fun String.htmlToPlainText(): String = * [htmlToPlainText] 와 달리 Android 에 의존하지 않는다 — ViewModel 판정에 쓰이므로 순수 * JVM 단위 테스트로 돌아야 한다. */ -fun String.isHtmlBlank(): Boolean = - replace(HTML_TAG, "") - .replace(HTML_ENTITY) { match -> if (match.value == " ") " " else "" } +fun String.isHtmlBlank(): Boolean { + // 태그를 통째로 걷으면 이미지·링크처럼 **태그 자체가 내용인** 본문이 빈 것으로 접힌다. + // 사진만 첨부한 상태에서 이어쓰기가 도착하면 그 이미지가 draft 로 덮인다 (리뷰 지적). + if (HTML_MEDIA_TAG.containsMatchIn(this)) return false + + return replace(HTML_TAG, "") + // 공백 엔티티만 공백으로, 나머지 엔티티는 **가시 문자 한 자**로 친다. 종전에는 + // 전부 지워 `

<

` 같은 본문이 빈 것으로 판정됐다. + .replace(HTML_ENTITY) { match -> if (match.value in HTML_SPACE_ENTITIES) " " else "\uFFFD" } .isBlank() +} private val HTML_TAG = Regex("<[^>]*>") private val HTML_ENTITY = Regex("&[a-zA-Z]+;|&#\\d+;") + +/** 태그 자체가 내용인 것들 — 걷어내면 «비었다» 로 뒤집힌다. */ +private val HTML_MEDIA_TAG = Regex("""<(img|video|audio|iframe|embed)\b""", RegexOption.IGNORE_CASE) + +/** 공백으로 렌더되는 엔티티. 나머지는 가시 문자로 본다. */ +private val HTML_SPACE_ENTITIES = setOf(" ", " ", " ", " ", " ") diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt index 1b08a9d117..5a5fc18fe5 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteUiState.kt @@ -30,7 +30,13 @@ data class DailyQuestionWriteUiState( * 이 눌린 시점에 사유를 알리고 조회를 재시도한다. */ val canSubmit: Boolean - get() = answer.isNotBlank() && !isQuestionLoading && submitState != SubmitState.InProgress + get() = + answer.isNotBlank() && + !isQuestionLoading && + // 이어쓸 본문이 도착하기 전에 저장하면 draftId 가 아직 null 이라 POST 로 나가고, + // 서버가 questionId upsert 라 기존 임시저장 본문이 덮인다 — #923 과 같은 유실이다. + !isResumingDraft && + submitState != SubmitState.InProgress } sealed interface SubmitState { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt index e490c99b7d..58d978d865 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModel.kt @@ -92,16 +92,20 @@ class DailyQuestionWriteViewModel return } _uiState.update { + // 빈 에디터는 `

` 를 내보내므로 `isBlank()` 로는 "비어 있음" 을 판정할 수 + // 없다. 태그를 걷어 낸 본문으로 판단해야 사용자가 아무것도 안 쓴 상태에서 + // 이어쓸 내용이 실린다 (#923). + val appliesDraft = it.answer.isHtmlBlank() it.copy( draftId = draft.dailyQuestionId, - // 빈 에디터는 `

` 를 내보내므로 `isBlank()` 로는 "비어 있음" 을 판정할 수 - // 없다. 태그를 걷어 낸 본문으로 판단해야 사용자가 아무것도 안 쓴 상태에서 - // 이어쓸 내용이 실린다 (#923). - answer = if (it.answer.isHtmlBlank()) draft.content else it.answer, + answer = if (appliesDraft) draft.content else it.answer, imageUrl = it.imageUrl ?: draft.imageUrl, isResumingDraft = false, // 화면이 이 전환을 보고 에디터를 재생성해 본문을 다시 싣는다 (#923). - draftLoaded = true, + // **다시 실을 값이 있을 때만** 뒤집는다 — 사용자가 이미 쓴 내용을 보존한 + // 경우까지 재생성하면 커서·포커스·IME 조합만 리셋되고, 진행 중이던 + // 업로드가 끊길 수 있다 (리뷰 지적). + draftLoaded = it.draftLoaded || appliesDraft, ) } } @@ -132,6 +136,10 @@ class DailyQuestionWriteViewModel val state = _uiState.value if (state.submitState == SubmitState.InProgress) return + // 하단 툴바 임시저장은 `enabled` 없는 clickable 이라 canSubmit 을 우회한다. + // 차단은 화면이 아니라 여기서 지킨다. + if (state.isResumingDraft) return + if (state.answer.isBlank()) { failSubmit(R.string.mindrecord_error_daily_question_answer_required) return diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlBlankTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlBlankTest.kt new file mode 100644 index 0000000000..0fa89de366 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/util/HtmlBlankTest.kt @@ -0,0 +1,58 @@ +package com.afternote.feature.mindrecord.presentation.util + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * "화면이 비었는가" 판정 (#923). + * + * 리치 에디터는 아무것도 입력하지 않아도 `

` 를 내보내므로 `isBlank()` 로는 판정할 + * 수 없다. 이 값을 "사용자가 이미 썼다" 로 오해하면 이어쓸 임시저장 본문이 실리지 않는다. + * + * 반대 방향도 같은 무게다 — 태그를 통째로 걷으면 **태그 자체가 내용인** 본문(이미지·링크)이 + * 빈 것으로 접혀, 사진만 첨부한 상태가 도착한 draft 로 덮인다 (리뷰 지적). + */ +class HtmlBlankTest { + @Test + fun `빈 에디터가 내보내는 문단은 비어 있다`() { + assertTrue("

".isHtmlBlank()) + assertTrue("".isHtmlBlank()) + assertTrue("


".isHtmlBlank()) + } + + @Test + fun `글이 있으면 비어 있지 않다`() { + assertFalse("

한 글자

".isHtmlBlank()) + } + + @Test + fun `이미지만 있어도 비어 있지 않다`() { + // 종전 판정에서는 태그를 걷어 빈 문자열이 됐다. 구 `isBlank()` 는 보호하던 입력이다. + assertFalse("".isHtmlBlank()) + assertFalse("

".isHtmlBlank()) + } + + @Test + fun `다른 미디어 태그도 내용으로 본다`() { + assertFalse("".isHtmlBlank()) + } + + @Test + fun `가시 문자 엔티티만 있어도 비어 있지 않다`() { + assertFalse("

<

".isHtmlBlank()) + assertFalse("

&

".isHtmlBlank()) + } + + @Test + fun `공백 엔티티만 있으면 비어 있다`() { + assertTrue("

 

".isHtmlBlank()) + assertTrue("

  

".isHtmlBlank()) + } + + @Test + fun `img 를 흉내 낸 이름에는 걸리지 않는다`() { + // ``·`` 는 img 가 아니다 — 단어 경계로 막는다. + assertTrue("

".isHtmlBlank()) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt index 19138a41b5..99e7184def 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -58,31 +58,79 @@ class DailyQuestionResumeDraftTest { @Test fun `이어쓸 본문을 불러오는 동안에는 저장할 수 없다`() { - val state = DailyQuestionWriteUiState(answer = "무언가", isResumingDraft = true) + // isQuestionLoading 을 명시하지 않으면 기본값(true)만으로 canSubmit 이 false 가 돼 + // isResumingDraft 를 통째로 지워도 통과하는 공허한 단언이 된다 (리뷰 지적). + // 실기 순서상 loadTodayQuestion 이 isQuestionLoading=false 를 먼저 커밋한 뒤 + // resumeDraft 가 돌므로, 그 창을 그대로 재현한다. + val state = + DailyQuestionWriteUiState( + answer = "무언가", + isQuestionLoading = false, + isResumingDraft = true, + ) assertEquals(false, state.canSubmit) } - private fun viewModel(currentAnswerFromEditor: String): DailyQuestionWriteViewModel { - val repository = - FakeResumeRepository( - today = - TodayDailyQuestion( - questionId = 1L, - day = 16, - content = "질문", - isAnswered = false, - isDraft = true, - ), - draft = - DailyQuestion( - dailyQuestionId = 7L, - title = "질문", - content = "

이어쓸 본문

", - createdAt = "2026.08.23 일", - isDraft = true, - ), + @Test + fun `불러오기가 끝나면 저장이 다시 열린다`() { + // 차단만 걸고 복귀를 빠뜨리면 저장이 영구히 잠긴다. + val state = + DailyQuestionWriteUiState( + answer = "무언가", + isQuestionLoading = false, + isResumingDraft = false, ) + + assertEquals(true, state.canSubmit) + } + + @Test + fun `이어쓰기가 끝나면 불러오기 상태가 풀리고 draftId 가 남는다`() { + val viewModel = viewModel(currentAnswerFromEditor = "

") + + assertEquals(false, viewModel.uiState.value.isResumingDraft) + assertEquals(7L, viewModel.uiState.value.draftId) + } + + @Test + fun `임시저장만 조회한다`() { + // 서버는 draftOnly 없이 조회하면 임시저장을 제외한 답변만 내려준다 — + // 인자를 흘리면 이어쓰기가 조용히 무산된다. + val repository = fakeRepository() + val viewModel = + DailyQuestionWriteViewModel( + repository = repository, + photoUploadRepository = PhotoUploadRepository { _, _ -> Result.success("") }, + ) + viewModel.onAnswerChanged("

") + repository.releaseDraft() + + assertEquals(true, repository.lastDraftOnly) + } + + private fun fakeRepository(): FakeResumeRepository = + FakeResumeRepository( + today = + TodayDailyQuestion( + questionId = 1L, + day = 16, + content = "질문", + isAnswered = false, + isDraft = true, + ), + draft = + DailyQuestion( + dailyQuestionId = 7L, + title = "질문", + content = "

이어쓸 본문

", + createdAt = "2026.08.23 일", + isDraft = true, + ), + ) + + private fun viewModel(currentAnswerFromEditor: String): DailyQuestionWriteViewModel { + val repository = fakeRepository() val viewModel = DailyQuestionWriteViewModel( repository = repository, @@ -109,10 +157,17 @@ private class FakeResumeRepository( todayArrived.complete(Unit) } + /** 마지막으로 받은 `draftOnly` — 인자를 흘리지 않는지 테스트가 본다. */ + var lastDraftOnly: Boolean? = null + private set + override suspend fun getList( date: String?, draftOnly: Boolean?, - ): Result> = Result.success(listOf(draft)) + ): Result> { + lastDraftOnly = draftOnly + return Result.success(listOf(draft)) + } override suspend fun getToday(): Result { todayArrived.await() From 48be2eead5bd460627c959310ef45e62aa693953 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 21:58:23 +0900 Subject: [PATCH 4/5] =?UTF-8?q?test(mindrecord):=20=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20loa?= =?UTF-8?q?der=20=EC=83=9D=EC=84=B1=EC=9E=90=EC=99=80=20Long=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EA=B3=84=EC=95=BD=EC=97=90=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit develop 이 들여온 #769(draftLoader)·#573(Result)에 맞춘다. 같은 패키지에 이미 동명 fake 가 있어 이 파일 것은 이름을 달리한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../viewmodel/DailyQuestionResumeDraftTest.kt | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt index 99e7184def..e920918e79 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -4,8 +4,12 @@ import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.feature.mindrecord.domain.model.DailyQuestion import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -102,6 +106,12 @@ class DailyQuestionResumeDraftTest { DailyQuestionWriteViewModel( repository = repository, photoUploadRepository = PhotoUploadRepository { _, _ -> Result.success("") }, + // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). + draftLoader = + MindRecordDraftLoader( + diaryRepository = NoDiaryDraftsRepository, + dailyQuestionRepository = repository, + ), ) viewModel.onAnswerChanged("

") repository.releaseDraft() @@ -135,6 +145,12 @@ class DailyQuestionResumeDraftTest { DailyQuestionWriteViewModel( repository = repository, photoUploadRepository = PhotoUploadRepository { _, _ -> Result.success("") }, + // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). + draftLoader = + MindRecordDraftLoader( + diaryRepository = NoDiaryDraftsRepository, + dailyQuestionRepository = repository, + ), ) // 에디터가 컴포지션 직후 현재 HTML 을 되돌려 주는 것을 재현한다. viewModel.onAnswerChanged(currentAnswerFromEditor) @@ -174,12 +190,12 @@ private class FakeResumeRepository( return Result.success(today) } - override suspend fun create(payload: DailyQuestionCreatePayload): Result = Result.success(Unit) + override suspend fun create(payload: DailyQuestionCreatePayload): Result = Result.success(1L) override suspend fun update( id: Long, payload: DailyQuestionUpdatePayload, - ): Result = Result.success(Unit) + ): Result = Result.success(1L) override suspend fun delete(id: Long): Result = error("호출되면 안 됨") } From d05123469384f62afb0123fd9b5a2c60f8b030e4 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Mon, 24 Aug 2026 21:59:27 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test(mindrecord):=20=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20?= =?UTF-8?q?=EB=B9=88=20=EC=9D=BC=EA=B8=B0=20=EC=A0=80=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?fake=20=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .../viewmodel/DailyQuestionResumeDraftTest.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt index e920918e79..2e18366b6d 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -199,3 +199,20 @@ private class FakeResumeRepository( override suspend fun delete(id: Long): Result = error("호출되면 안 됨") } + +/** 툴바 카운트 조회만 받아 주는 빈 일기 저장소. 같은 패키지에 동명 fake 가 있어 이름을 달리한다. */ +private object NoDiaryDraftsRepository : DiaryRepository { + override suspend fun getList( + yearMonth: String, + draftOnly: Boolean?, + ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + + override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") + + override suspend fun update( + id: Long, + payload: DiaryUpdatePayload, + ): Result = error("호출되면 안 됨") + + override suspend fun delete(id: Long): Result = error("호출되면 안 됨") +}