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 4f17879357..7167e4b8be 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 @@ -119,14 +120,18 @@ fun DailyQuestionWriteScreen( } Spacer(modifier = Modifier.height(16.dp)) - WriteTextField( - value = uiState.answer, - onValueChange = viewModel::onAnswerChanged, - onSaveDraftClick = { viewModel.submit(isDraft = true) }, - onDraftCountClick = onDraftListClick, - draftCount = uiState.draftCount, - 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, + draftCount = uiState.draftCount, + onImagePicked = viewModel::uploadImage, + ) + } } } } 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..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 @@ -14,3 +14,34 @@ fun String.htmlToPlainText(): String = .fromHtml(this, HtmlCompat.FROM_HTML_MODE_COMPACT) .toString() .trim() + +/** + * 태그만 있고 보이는 글자가 없으면 비었다고 본다. + * + * 리치 에디터는 아무것도 입력하지 않아도 `

` 를 내보내므로 `isBlank()` 로는 "화면이 + * 비었는지" 를 판정할 수 없다. 그 값을 "사용자가 이미 썼다" 로 오해하면 이어쓸 임시저장 + * 본문이 실리지 않고, 그대로 저장할 때 기존 내용이 덮인다 (#923). + * + * [htmlToPlainText] 와 달리 Android 에 의존하지 않는다 — ViewModel 판정에 쓰이므로 순수 + * JVM 단위 테스트로 돌아야 한다. + */ +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 6349d2a4f6..55c2580fc2 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, /** 툴바 "임시저장 N" 표시값. `null` 은 아직 모름(조회 중·실패) (#769). */ val draftCount: Int? = null, @@ -23,7 +32,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 f893d48f9d..c9f950b5ba 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.isHtmlBlank import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -91,17 +92,31 @@ 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 { + // 빈 에디터는 `

` 를 내보내므로 `isBlank()` 로는 "비어 있음" 을 판정할 수 + // 없다. 태그를 걷어 낸 본문으로 판단해야 사용자가 아무것도 안 쓴 상태에서 + // 이어쓸 내용이 실린다 (#923). + val appliesDraft = it.answer.isHtmlBlank() it.copy( draftId = draft.dailyQuestionId, - answer = if (it.answer.isBlank()) draft.content else it.answer, + answer = if (appliesDraft) draft.content else it.answer, imageUrl = it.imageUrl ?: draft.imageUrl, + isResumingDraft = false, + // 화면이 이 전환을 보고 에디터를 재생성해 본문을 다시 싣는다 (#923). + // **다시 실을 값이 있을 때만** 뒤집는다 — 사용자가 이미 쓴 내용을 보존한 + // 경우까지 재생성하면 커서·포커스·IME 조합만 리셋되고, 진행 중이던 + // 업로드가 끊길 수 있다 (리뷰 지적). + draftLoaded = it.draftLoaded || appliesDraft, ) } } @@ -132,6 +147,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 new file mode 100644 index 0000000000..2e18366b6d --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionResumeDraftTest.kt @@ -0,0 +1,218 @@ +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.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 +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 + +/** + * 이어쓰기가 임시저장 본문을 싣는지 고정한다 (#923). + * + * 빈 에디터는 `

` 를 내보낸다. `isBlank()` 로 "화면이 비었는지" 를 판정하면 이 값이 + * 비어 있지 않다고 나와 draft 본문이 실리지 않고, 그대로 저장하면 기존 내용이 빈 값으로 + * 덮인다 — 되돌릴 수 없는 유실이다. + * + * 판정은 Android 에 의존하지 않는 `isHtmlBlank()` 가 맡으므로 순수 JVM 에서 돈다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +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 `이어쓸 본문을 불러오는 동안에는 저장할 수 없다`() { + // isQuestionLoading 을 명시하지 않으면 기본값(true)만으로 canSubmit 이 false 가 돼 + // isResumingDraft 를 통째로 지워도 통과하는 공허한 단언이 된다 (리뷰 지적). + // 실기 순서상 loadTodayQuestion 이 isQuestionLoading=false 를 먼저 커밋한 뒤 + // resumeDraft 가 돌므로, 그 창을 그대로 재현한다. + val state = + DailyQuestionWriteUiState( + answer = "무언가", + isQuestionLoading = false, + isResumingDraft = true, + ) + + assertEquals(false, state.canSubmit) + } + + @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("") }, + // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). + draftLoader = + MindRecordDraftLoader( + diaryRepository = NoDiaryDraftsRepository, + dailyQuestionRepository = repository, + ), + ) + 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, + photoUploadRepository = PhotoUploadRepository { _, _ -> Result.success("") }, + // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). + draftLoader = + MindRecordDraftLoader( + diaryRepository = NoDiaryDraftsRepository, + dailyQuestionRepository = repository, + ), + ) + // 에디터가 컴포지션 직후 현재 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) + } + + /** 마지막으로 받은 `draftOnly` — 인자를 흘리지 않는지 테스트가 본다. */ + var lastDraftOnly: Boolean? = null + private set + + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> { + lastDraftOnly = draftOnly + return Result.success(listOf(draft)) + } + + override suspend fun getToday(): Result { + todayArrived.await() + return Result.success(today) + } + + override suspend fun create(payload: DailyQuestionCreatePayload): Result = Result.success(1L) + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result = Result.success(1L) + + 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("호출되면 안 됨") +}