From 8cc64803a6f4a52eb3c397eabb8e40d9bd0f02cd Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 20:36:33 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat(mindrecord):=20=EC=9E=91=EC=84=B1=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=EB=A5=BC=20=ED=85=94=EB=A0=88=EB=A9=94?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EC=97=90=20=EB=82=A8=EA=B8=B4=EB=8B=A4=20(?= =?UTF-8?q?=EC=93=B0=EA=B8=B0=201=EC=88=9C=EC=9C=84=203=EC=A2=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 모듈은 Log.e 도 ErrorReporter 도 0건이라, 사용자가 방금 쓴 글이 서버에 닿지 못해도 릴리즈에서 알 방법이 없었다. #1109 가 읽기 3종(임시저장 목록·삭제·수신자 열람)을 세웠고, #964 체크리스트의 «쓰기 실패 — 승격 1순위» 가 남아 있었다. 승격한 자리 셋: - DIARY_SUBMIT — 일기 저장(정식·임시저장 공통). 방금 쓴 글이 사라지는 자리다 - DAILY_QUESTION_SUBMIT — 데일리질문 답변 저장. upsert 라 실패가 기존 임시저장과 얽힌다 - MEDIA_UPLOAD — 작성 화면 미디어 업로드. 실패하면 첨부만 빠진 채 저장이 이어져 «올린 줄 알았는데 없는» 기록이 남는다 일괄 치환하지 않았다. Crashlytics 가 non-fatal 을 최근 8건만 보관하므로, 흔한 읽기 실패까지 넣으면 잡음이 한도를 채워 실제 장애를 민다 — 사용자가 쓴 것이 날아가고 재현이 어려운 경로만 골랐다. 가드 4건: 세 stage 가 각각 그 문자열로 기록되는지와, **성공은 기록하지 않는지**. 뒤가 없으면 «항상 기록» 으로 만들어도 앞이 통과한다. 계측을 빼면 실패하는 것을 확인했다. stage 문자열까지 고정한 이유는 콘솔 필터가 그 값이라 바뀌면 추이가 끊기기 때문이다. ktlintCheck · testDebugUnitTest · :app:assembleDebug green, MindRecordFlowAndroidTest 는 CI 와 같은 pixel2Api30 에서 3/3 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../afternote_fe/MindRecordFlowAndroidTest.kt | 3 + ...meLetterMindRecordCompletionAndroidTest.kt | 2 + .../reporting/MindRecordFailureReporting.kt | 18 +++ .../viewmodel/DailyQuestionWriteViewModel.kt | 10 +- .../viewmodel/DiaryWriteViewModel.kt | 11 +- .../viewmodel/DailyQuestionEditTest.kt | 2 + .../viewmodel/DailyQuestionResumeDraftTest.kt | 3 + .../DailyQuestionWriteViewModelTest.kt | 9 ++ .../viewmodel/DiaryWriteImageKeyTest.kt | 2 + .../MindRecordFailureRecoveryTest.kt | 2 + .../viewmodel/ReceiverLoadFailureTest.kt | 4 + .../viewmodel/WriteFailureReportingTest.kt | 150 ++++++++++++++++++ 12 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt index 13bd9f4bd7..2779e99faf 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.test.performClick import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule +import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.afternote_fe.test.appTestUserRepository import com.afternote.core.domain.testing.FakePhotoUploadRepository import com.afternote.core.ui.theme.AfternoteTheme @@ -131,6 +132,7 @@ class MindRecordFlowAndroidTest { uploadedKey = "mindrecords/1/question.jpg", ), draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + errorReporter = FakeErrorReporter(), ) composeRule.setContent { AfternoteTheme {} } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isQuestionLoading } @@ -175,5 +177,6 @@ class MindRecordFlowAndroidTest { ), userRepository = appTestUserRepository(), draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), + errorReporter = FakeErrorReporter(), ) } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt index 62757fad6c..41d640388a 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -436,6 +436,7 @@ class TimeLetterMindRecordCompletionAndroidTest { repository = repository, photoUploadRepository = FakePhotoUploadRepository.strict(), draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + errorReporter = FakeErrorReporter(), ) } composeRule.waitUntil(timeoutMillis = TIMEOUT) { @@ -522,6 +523,7 @@ class TimeLetterMindRecordCompletionAndroidTest { userRepository = appTestUserRepository(), draftLoader = MindRecordDraftLoader(repository, draftDailyQuestionRepository), + errorReporter = FakeErrorReporter(), ) }, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt index 55f061595e..4cda23c977 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/reporting/MindRecordFailureReporting.kt @@ -29,6 +29,24 @@ enum class MindRecordFailureStage( */ DRAFT_DELETE("draft_delete"), + /** + * 일기 저장(정식 등록·임시저장 공통). 사용자가 방금 쓴 글이 서버에 닿지 못한 자리라 + * 재현이 어렵고, 실패하면 작성물이 그대로 사라진다 — 실기 QA 로는 잡히지 않는다. + */ + DIARY_SUBMIT("diary_submit"), + + /** + * 데일리질문 답변 저장. 위와 같은 성질이고, upsert 라 실패가 기존 임시저장 상태와도 + * 얽힌다 (#1018). + */ + DAILY_QUESTION_SUBMIT("daily_question_submit"), + + /** + * 작성 화면의 미디어 업로드. 실패하면 본문에서 그 첨부만 빠진 채 저장이 이어질 수 있어, + * 사용자는 «올린 줄 알았는데 없는» 기록을 남기게 된다 (#716·#731). + */ + MEDIA_UPLOAD("media_upload"), + /** * 수신자가 받은 기록 열람. 유가족이 «지금 못 여는» 상황이라 읽기 실패지만 승격 가치가 * 높다 — 재현할 계정도 조건도 우리 손에 없어 실기 QA 로 잡히지 않는다. 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 821469024a..95c255c182 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 @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload @@ -12,6 +13,8 @@ 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.navigation.MindRecordRoute +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel @@ -31,6 +34,7 @@ class DailyQuestionWriteViewModel private val repository: DailyQuestionRepository, private val photoUploadRepository: PhotoUploadRepository, private val draftLoader: MindRecordDraftLoader, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val _uiState = MutableStateFlow(DailyQuestionWriteUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -205,7 +209,9 @@ class DailyQuestionWriteViewModel // 제출 직전 fileKey 로 바뀔 수 있게 기억만 해 둔다 (#549). uploadedImageUrls += uploaded.fileUrl _uiState.update { it.copy(isUploadingImage = false) } - }.onFailure { + }.onFailure { e -> + // 첨부가 빠진 채 저장이 이어질 수 있는 자리라 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEDIA_UPLOAD, e) // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). _uiState.update { it.copy( @@ -301,6 +307,8 @@ class DailyQuestionWriteViewModel // 이미 옮겨진 파일을 다시 옮기려다 실패한다 (#549). uploadedImageUrls.clear() }.onFailure { e -> + // 저장이 upsert 라 실패가 기존 임시저장 상태와도 얽힌다 (#964·#1018). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DAILY_QUESTION_SUBMIT, e) _uiState.update { it.copy( submitState = diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt index 2dc91250b6..f478a8b761 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.common.result.runCatchingCancellable import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.domain.repository.UserRepository @@ -15,6 +16,8 @@ import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi import com.afternote.feature.mindrecord.presentation.navigation.MindRecordRoute +import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage +import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -43,6 +46,7 @@ class DiaryWriteViewModel private val photoUploadRepository: PhotoUploadRepository, private val userRepository: UserRepository, private val draftLoader: MindRecordDraftLoader, + private val errorReporter: ErrorReporter, ) : ViewModel() { private val route = savedStateHandle.toRoute() @@ -123,7 +127,9 @@ class DiaryWriteViewModel val withUrl = if (it.imageUrl == null) it.copy(imageUrl = uploaded.fileUrl) else it withUrl.copy(isUploadingImage = false) } - }.onFailure { + }.onFailure { e -> + // 첨부가 빠진 채 저장이 이어질 수 있는 자리라 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.MEDIA_UPLOAD, e) // null 로 흡수하면 사용자는 이미지가 붙은 줄 알고 저장한다 (#716). _uiState.update { it.copy( @@ -177,6 +183,9 @@ class DiaryWriteViewModel // 임시저장이 하나 늘었으니 툴바 숫자도 따라가야 한다 (#769). if (isDraft) loadDraftCount() }.onFailure { e -> + // 방금 쓴 글이 서버에 닿지 못한 자리다 — 재현이 어려워 실기 QA 로 + // 잡히지 않으므로 텔레메트리에 남긴다 (#964). + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DIARY_SUBMIT, e) _uiState.update { it.copy( submitState = diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt index f95aa683f5..b128c9def9 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionEditTest.kt @@ -11,6 +11,7 @@ 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 com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -161,6 +162,7 @@ class DailyQuestionEditTest { diaryRepository = EditTestEmptyDiaryRepository, dailyQuestionRepository = EditTestEmptyDailyQuestionRepository, ), + RecordingErrorReporter(), ) } 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 974649175a..908d20ee87 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 @@ -11,6 +11,7 @@ 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 com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -118,6 +119,7 @@ class DailyQuestionResumeDraftTest { diaryRepository = NoDiaryDraftsRepository, dailyQuestionRepository = repository, ), + errorReporter = RecordingErrorReporter(), ) viewModel.onAnswerChanged("

") repository.releaseDraft() @@ -158,6 +160,7 @@ class DailyQuestionResumeDraftTest { diaryRepository = NoDiaryDraftsRepository, dailyQuestionRepository = repository, ), + errorReporter = RecordingErrorReporter(), ) // 에디터가 컴포지션 직후 현재 HTML 을 되돌려 주는 것을 재현한다. viewModel.onAnswerChanged(currentAnswerFromEditor) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt index 0380146b47..017c69f977 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DailyQuestionWriteViewModelTest.kt @@ -7,6 +7,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -60,6 +61,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) viewModel.onAnswerChanged("사용자가 방금 입력한 답변") @@ -85,6 +87,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) assertEquals("이어쓸 본문", viewModel.uiState.value.answer) @@ -115,6 +118,7 @@ class DailyQuestionWriteViewModelTest { uploadedKey = "mindrecords/1/just-picked.jpg", ), noopDraftLoader(), + RecordingErrorReporter(), ) viewModel.onAnswerChanged("사용자가 방금 입력한 답변") @@ -143,6 +147,7 @@ class DailyQuestionWriteViewModelTest { uploadedKey = "mindrecords/staging/13/picked.jpg", ), noopDraftLoader(), + RecordingErrorReporter(), ) val url = runBlocking { viewModel.uploadMedia("content://picked") } @@ -168,6 +173,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) assertEquals(html, viewModel.uiState.value.answer) @@ -186,6 +192,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) viewModel.onAnswerChanged("답변") @@ -236,6 +243,7 @@ class DailyQuestionWriteViewModelTest { uploadedKey = "mindrecords/staging/13/a.png", ), noopDraftLoader(), + RecordingErrorReporter(), ) val previewUrl = runBlocking { viewModel.uploadMedia("content://picked") } @@ -264,6 +272,7 @@ class DailyQuestionWriteViewModelTest { repository, FakePhotoUploadRepository.strict(), noopDraftLoader(), + RecordingErrorReporter(), ) viewModel.onAnswerChanged("

수정

") diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt index e06a8ec8cc..83a76f4d82 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DiaryWriteImageKeyTest.kt @@ -9,6 +9,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -80,6 +81,7 @@ class DiaryWriteImageKeyTest { ), userRepository = noReceiverUserRepository(), draftLoader = MindRecordDraftLoader(RecordingDiaryRepository {}, NoDailyQuestionRepository), + errorReporter = RecordingErrorReporter(), ) private companion object { diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt index 57a8edd6d0..2dc89e1be7 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordFailureRecoveryTest.kt @@ -12,6 +12,7 @@ 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 com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -121,6 +122,7 @@ class MindRecordFailureRecoveryTest { diaryRepository = EmptyDiaryRepository, dailyQuestionRepository = repository, ), + errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt index c61e372626..cbb4224f7a 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/ReceiverLoadFailureTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -98,6 +99,7 @@ class ReceiverLoadFailureTest { if (shouldFail) throw IOException("offline") else emptyList() }, draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() assertNotNull(viewModel.uiState.value.receiverLoadError) @@ -137,6 +139,7 @@ class ReceiverLoadFailureTest { } }, draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() assertNotNull(viewModel.uiState.value.receiverLoadError) @@ -161,6 +164,7 @@ class ReceiverLoadFailureTest { photoUploadRepository = FakePhotoUploadRepository.strict(), userRepository = userRepository { failure?.let { throw it } ?: emptyList() }, draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + errorReporter = RecordingErrorReporter(), ) /** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt new file mode 100644 index 0000000000..d5edd0fc94 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt @@ -0,0 +1,150 @@ +package com.afternote.feature.mindrecord.presentation.viewmodel + +import androidx.lifecycle.SavedStateHandle +import com.afternote.core.domain.repository.PhotoUploadRepository +import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.io.IOException +import java.lang.reflect.Proxy + +/** + * 작성 실패가 텔레메트리에 남는지 (#964). + * + * 이 모듈은 종전에 `Log.e` 도 `ErrorReporter` 도 0건이라, 사용자가 방금 쓴 글이 서버에 닿지 + * 못해도 릴리즈에서 알 방법이 없었다. 재현이 어려워 실기 QA 로는 잡히지 않는 자리다. + * + * **stage 값까지 고정한다.** 콘솔 필터가 그 문자열이라, 바뀌면 그 시점 이후 기록이 기존 이슈 + * 그룹과 끊겨 추이를 잃는다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class WriteFailureReportingTest { + private val dispatcher = StandardTestDispatcher() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun `일기 저장 실패가 diary_submit 으로 기록된다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val repository = + FakeDiaryRepository().apply { + onCreate = { Result.failure(IOException("offline")) } + } + val viewModel = diaryViewModel(repository, reporter) + advanceUntilIdle() + + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + viewModel.onMoodSelected(TodayMood.SOSO) + viewModel.submit(isDraft = false) + advanceUntilIdle() + + assertEquals(listOf("diary_submit"), reporter.stages) + } + + @Test + fun `일기 저장 성공은 기록하지 않는다`() = + runTest(dispatcher) { + // 성공까지 남기면 Crashlytics 보관 한도(최근 8건)를 잡음으로 채워 실제 장애를 민다. + val reporter = RecordingErrorReporter() + val viewModel = diaryViewModel(FakeDiaryRepository(), reporter) + advanceUntilIdle() + + viewModel.onTitleChanged("제목") + viewModel.onContentChanged("

본문

") + viewModel.onMoodSelected(TodayMood.SOSO) + viewModel.submit(isDraft = false) + advanceUntilIdle() + + assertEquals(emptyList(), reporter.stages) + } + + @Test + fun `데일리질문 저장 실패가 daily_question_submit 으로 기록된다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val repository = + FakeDailyQuestionRepository().apply { + onCreate = { Result.failure(IOException("offline")) } + } + val viewModel = + DailyQuestionWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = repository, + photoUploadRepository = failingUpload(), + draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + errorReporter = reporter, + ) + advanceUntilIdle() + + viewModel.onAnswerChanged("

답변

") + viewModel.submit() + advanceUntilIdle() + + assertEquals(listOf("daily_question_submit"), reporter.stages) + } + + @Test + fun `미디어 업로드 실패가 media_upload 으로 기록된다`() = + runTest(dispatcher) { + val reporter = RecordingErrorReporter() + val viewModel = diaryViewModel(FakeDiaryRepository(), reporter) + advanceUntilIdle() + + viewModel.uploadMedia("content://media/1") + advanceUntilIdle() + + assertEquals(listOf("media_upload"), reporter.stages) + } + + private fun diaryViewModel( + repository: FakeDiaryRepository, + reporter: RecordingErrorReporter, + ): DiaryWriteViewModel = + DiaryWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = repository, + photoUploadRepository = failingUpload(), + userRepository = emptyReceiverRepository(), + draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), + errorReporter = reporter, + ) + + private fun failingUpload(): PhotoUploadRepository = PhotoUploadRepository { _, _ -> Result.failure(IOException("upload offline")) } + + /** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ + private fun emptyReceiverRepository(): UserRepository = + Proxy.newProxyInstance( + UserRepository::class.java.classLoader, + arrayOf(UserRepository::class.java), + ) { _, method, _ -> + when (method.name) { + "getReceivers" -> emptyList() + "getReceiverListFlow" -> flowOf(emptyList()) + else -> error("Unexpected call: ${method.name}") + } + } as UserRepository +} From 676cba181d53c7a0bc7024d26d214de96ad7f86b Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 27 Aug 2026 20:45:15 +0900 Subject: [PATCH 2/6] =?UTF-8?q?chore:=20QA=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EC=97=90=20androidTest=20=EA=B2=B0=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EC=B1=84=EC=9A=B0=EA=B3=A0=20=EC=9E=AC=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repository Quality 가 계측 테스트 필요 여부의 명시적 결정을 요구한다. Co-Authored-By: Claude Opus 5 (1M context) From 5be7cb9ce017ce201c49209db26adf325d351431 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 28 Aug 2026 12:27:56 +0900 Subject: [PATCH 3/6] =?UTF-8?q?test(mindrecord):=20=EA=B3=84=EC=B8=A1?= =?UTF-8?q?=EC=9D=B4=20=EC=8B=A4=EC=A0=9C=EB=A1=9C=20=ED=9A=8C=EA=B7=80?= =?UTF-8?q?=EB=A5=BC=20=EC=9E=A1=EB=8F=84=EB=A1=9D=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EC=98=AE=EA=B8=B4=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 리뷰 지적대로 앞선 가드 셋이 아무것도 지키지 못하고 있었다. - androidTest 가 FakeErrorReporter() 를 만들자마자 버려, 새 계측 호출을 지워도 통과했다. reporter 를 밖에서 보존해 diary_submit 이 **정확히 1건**이고 성공한 재시도가 더 남기지 않는 것까지 단언한다 - 데일리질문 계측 테스트가 빈 테마만 compose 하고 submit() 을 직접 불러, 화면-ViewModel 결선을 지나지 않았다. 실제 DailyQuestionWriteScreen 을 띄우고 저장 버튼 클릭으로 실패를 만든다 — 화면을 태우니 에디터가 되돌려 주는 HTML(`

…

`)이 실제 값이라 기대값도 프로덕션 형태로 바뀌었다 - media_upload 가드가 일기 쪽만 봐서, 데일리질문 호출부를 지워도 통과했다. 같은 stage 의 두 호출부를 각각 가드한다 역검증 3종 모두 확인했다 — 각 계측 호출을 지우면 그 테스트가 실패한다(실기는 CI 와 같은 pixel2Api30 에서). PR 본문의 QA Metadata 는 develop 이 CI Test Plan 으로 교체해 그 형식으로 옮겼다. Co-Authored-By: Claude Opus 5 (1M context) --- .../afternote_fe/MindRecordFlowAndroidTest.kt | 47 +++++++++++++------ .../afternote/afternote_fe/test/TestFakes.kt | 4 ++ .../viewmodel/WriteFailureReportingTest.kt | 26 ++++++++++ 3 files changed, 62 insertions(+), 15 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt index 2779e99faf..a157fe5c66 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt @@ -19,6 +19,7 @@ import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel @@ -53,7 +54,8 @@ class MindRecordFlowAndroidTest { ), ) repository.onCreate = { createResults.removeFirst() } - val viewModel = diaryViewModel(repository) + val reporter = FakeErrorReporter() + val viewModel = diaryViewModel(repository, reporter) var successCalls = 0 composeRule.setContent { @@ -90,6 +92,10 @@ class MindRecordFlowAndroidTest { assertEquals(TodayMood.HAPPY, payload.todayMood) assertEquals(listOf(7L), payload.receiverIds) assertFalse(payload.isDraft) + + // 실패는 한 번뿐이었으므로 기록도 한 번이다. 성공한 재시도가 더 남기면 Crashlytics + // 보관 한도(최근 8건)를 잡음으로 채운다 (#964). + assertEquals(listOf("diary_submit"), reporter.mindRecordStages) } @Test @@ -122,6 +128,7 @@ class MindRecordFlowAndroidTest { ), ) repository.onCreate = { createResults.removeFirst() } + val reporter = FakeErrorReporter() val viewModel = DailyQuestionWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()), @@ -132,35 +139,45 @@ class MindRecordFlowAndroidTest { uploadedKey = "mindrecords/1/question.jpg", ), draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), - errorReporter = FakeErrorReporter(), + errorReporter = reporter, ) - composeRule.setContent { AfternoteTheme {} } + // 실제 작성 화면을 띄운다 — 빈 테마만 compose 하고 submit() 을 직접 부르면 화면과 + // ViewModel 사이 결선이 검증되지 않아, 계측 호출을 지워도 통과한다 (#964 리뷰). + composeRule.setContent { + AfternoteTheme { + DailyQuestionWriteScreen(viewModel = viewModel, onSubmitSuccess = { }) + } + } composeRule.waitUntil(timeoutMillis = 5_000) { !viewModel.uiState.value.isQuestionLoading } - composeRule.runOnIdle { - viewModel.onAnswerChanged("오늘은 용기 냈다") - viewModel.submit() - } + composeRule.runOnIdle { viewModel.onAnswerChanged("오늘은 용기 냈다") } + composeRule.onNode(hasText("저장") and hasClickAction()).performClick() composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.submitState is SubmitState.Failed } - assertEquals("오늘은 용기 냈다", viewModel.uiState.value.answer) + // 실제 화면을 태우므로 에디터가 되돌려 주는 HTML 이 그대로 상태에 들어간다 — + // 종전 테스트는 화면을 건너뛰어 원문 문자열이 남았다. + assertEquals("

오늘은 용기 냈다

", viewModel.uiState.value.answer) + assertEquals(listOf("daily_question_submit"), reporter.mindRecordStages) - composeRule.runOnIdle { - viewModel.consumeSubmitResult() - viewModel.submit() - } + composeRule.runOnIdle { viewModel.consumeSubmitResult() } + composeRule.onNode(hasText("저장") and hasClickAction()).performClick() composeRule.waitUntil(timeoutMillis = 5_000) { viewModel.uiState.value.submitState is SubmitState.Succeeded } assertEquals(2, repository.createdPayloads.size) assertEquals(repository.createdPayloads.first(), repository.createdPayloads.last()) - assertEquals("오늘은 용기 냈다", repository.createdPayloads.last().content) + assertEquals("

오늘은 용기 냈다

", repository.createdPayloads.last().content) assertFalse(repository.createdPayloads.last().isDraft) + // 성공한 재시도는 기록을 늘리지 않는다. + assertEquals(listOf("daily_question_submit"), reporter.mindRecordStages) } - private fun diaryViewModel(repository: FakeDiaryRepository): DiaryWriteViewModel = + private fun diaryViewModel( + repository: FakeDiaryRepository, + reporter: FakeErrorReporter = FakeErrorReporter(), + ): DiaryWriteViewModel = DiaryWriteViewModel( savedStateHandle = SavedStateHandle( @@ -177,6 +194,6 @@ class MindRecordFlowAndroidTest { ), userRepository = appTestUserRepository(), draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), - errorReporter = FakeErrorReporter(), + errorReporter = reporter, ) } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index da784e04bd..0c66be65c4 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -67,6 +67,10 @@ fun appTestUserRepository( class FakeErrorReporter : ErrorReporter { val failures = mutableListOf>>() + /** 기록된 마음의 기록 stage 목록. 콘솔 필터 값이라 문자열까지 고정한다 (#964). */ + val mindRecordStages: List + get() = failures.mapNotNull { it.second["mind_record_stage"] } + override fun writeFailure( throwable: Throwable, attributes: Map, diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt index d5edd0fc94..148c9b8876 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WriteFailureReportingTest.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull import org.junit.Before import org.junit.Test import java.io.IOException @@ -120,6 +121,31 @@ class WriteFailureReportingTest { assertEquals(listOf("media_upload"), reporter.stages) } + @Test + fun `데일리질문 미디어 업로드 실패도 media_upload 으로 기록된다`() = + runTest(dispatcher) { + // 같은 stage 라도 호출부가 둘이다 — 일기 쪽만 보면 데일리질문 계측 호출을 지워도 + // 통과한다 (#964 리뷰). + val reporter = RecordingErrorReporter() + val repository = FakeDailyQuestionRepository() + val viewModel = + DailyQuestionWriteViewModel( + savedStateHandle = SavedStateHandle(emptyMap()), + repository = repository, + photoUploadRepository = failingUpload(), + draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + errorReporter = reporter, + ) + advanceUntilIdle() + + viewModel.uploadMedia("content://media/1") + advanceUntilIdle() + + assertEquals(listOf("media_upload"), reporter.stages) + // 기록만 남기고 화면 안내를 빠뜨리면 사용자는 첨부가 붙은 줄 안다 (#716). + assertNotNull(viewModel.uiState.value.imageUploadError) + } + private fun diaryViewModel( repository: FakeDiaryRepository, reporter: RecordingErrorReporter, From b5b3aec9af3bf6dbfcbb8974f0dac55adafced47 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 28 Aug 2026 13:08:56 +0900 Subject: [PATCH 4/6] =?UTF-8?q?chore:=20=EB=B3=80=EA=B2=BD=ED=95=9C=20?= =?UTF-8?q?=EA=B3=84=EC=B8=A1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20CI?= =?UTF-8?q?=20Test=20Plan=20selector=20=EB=A1=9C=20=EC=84=A0=EC=96=B8?= =?UTF-8?q?=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) From 2b86faf3beda37d5c0263216880f9f036220db38 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 28 Aug 2026 13:16:11 +0900 Subject: [PATCH 5/6] =?UTF-8?q?chore:=20CI=20Test=20Plan=20=EC=9D=84=20ful?= =?UTF-8?q?l=20=EB=A1=9C=20=EC=A0=84=ED=99=98=ED=95=9C=EB=8B=A4=20?= =?UTF-8?q?=E2=80=94=20=EB=B3=80=EA=B2=BD=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EA=B0=80=2020=EA=B0=9C=20=EC=83=81=ED=95=9C=EC=9D=84=20?= =?UTF-8?q?=EB=84=98=EB=8A=94=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) From 94c906a065c9e068b338d6921b0229cd3921b831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=BC=ED=98=81?= Date: Fri, 28 Aug 2026 19:05:46 +0900 Subject: [PATCH 6/6] test: move mindrecord tests to JVM --- .../presentation/MindRecordFlowTest.kt | 62 +++++++++++-------- .../presentation/MindRecordLifecycleTest.kt | 36 +++++------ 2 files changed, 53 insertions(+), 45 deletions(-) rename app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt => feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt (84%) rename app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt => feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt (96%) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt similarity index 84% rename from app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt rename to feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt index a157fe5c66..1c196aee33 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt @@ -1,24 +1,20 @@ -package com.afternote.afternote_fe +package com.afternote.feature.mindrecord.presentation -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick import androidx.lifecycle.SavedStateHandle -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.afternote.afternote_fe.test.FailureArtifactRule -import com.afternote.afternote_fe.test.FakeErrorReporter -import com.afternote.afternote_fe.test.appTestUserRepository import com.afternote.core.domain.testing.FakePhotoUploadRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.core.model.user.Receiver import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.TodayMood import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel @@ -30,18 +26,17 @@ import org.junit.Assert.assertFalse import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith - -@RunWith(AndroidJUnit4::class) -class MindRecordFlowAndroidTest { - @get:Rule(order = 0) +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35]) +class MindRecordFlowTest { + @get:Rule val composeRule = createComposeRule() - @get:Rule(order = 1) - val failureArtifactRule = - FailureArtifactRule { - composeRule.onRoot().captureToImage().asAndroidBitmap() - } - @Test fun diaryFailureThenRetry_preservesInputAndSendsExactPayload() { val repository = FakeDiaryRepository() @@ -54,7 +49,7 @@ class MindRecordFlowAndroidTest { ), ) repository.onCreate = { createResults.removeFirst() } - val reporter = FakeErrorReporter() + val reporter = RecordingErrorReporter() val viewModel = diaryViewModel(repository, reporter) var successCalls = 0 @@ -95,7 +90,7 @@ class MindRecordFlowAndroidTest { // 실패는 한 번뿐이었으므로 기록도 한 번이다. 성공한 재시도가 더 남기면 Crashlytics // 보관 한도(최근 8건)를 잡음으로 채운다 (#964). - assertEquals(listOf("diary_submit"), reporter.mindRecordStages) + assertEquals(listOf("diary_submit"), reporter.stages) } @Test @@ -128,7 +123,7 @@ class MindRecordFlowAndroidTest { ), ) repository.onCreate = { createResults.removeFirst() } - val reporter = FakeErrorReporter() + val reporter = RecordingErrorReporter() val viewModel = DailyQuestionWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()), @@ -158,7 +153,7 @@ class MindRecordFlowAndroidTest { // 실제 화면을 태우므로 에디터가 되돌려 주는 HTML 이 그대로 상태에 들어간다 — // 종전 테스트는 화면을 건너뛰어 원문 문자열이 남았다. assertEquals("

오늘은 용기 냈다

", viewModel.uiState.value.answer) - assertEquals(listOf("daily_question_submit"), reporter.mindRecordStages) + assertEquals(listOf("daily_question_submit"), reporter.stages) composeRule.runOnIdle { viewModel.consumeSubmitResult() } composeRule.onNode(hasText("저장") and hasClickAction()).performClick() @@ -171,12 +166,12 @@ class MindRecordFlowAndroidTest { assertEquals("

오늘은 용기 냈다

", repository.createdPayloads.last().content) assertFalse(repository.createdPayloads.last().isDraft) // 성공한 재시도는 기록을 늘리지 않는다. - assertEquals(listOf("daily_question_submit"), reporter.mindRecordStages) + assertEquals(listOf("daily_question_submit"), reporter.stages) } private fun diaryViewModel( repository: FakeDiaryRepository, - reporter: FakeErrorReporter = FakeErrorReporter(), + reporter: RecordingErrorReporter = RecordingErrorReporter(), ): DiaryWriteViewModel = DiaryWriteViewModel( savedStateHandle = @@ -192,8 +187,23 @@ class MindRecordFlowAndroidTest { uploadedUrl = "https://cdn.test/image.jpg", uploadedKey = "mindrecords/1/image.jpg", ), - userRepository = appTestUserRepository(), + userRepository = mindRecordFlowUserRepository(), draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), errorReporter = reporter, ) } + +private fun mindRecordFlowUserRepository(): FakeUserRepository = + FakeUserRepository.strict().apply { + receiverState.value = listOf(Receiver(7L, "김수신", "가족", "fake-auth-7")) + onReceiverListFlow = null + onGetReceivers = null + onCreateReceiver = null + onGetMyProfile = null + onUpdateMyProfile = null + onDeleteAccount = null + onLogActivity = null + onGetMyPushSettings = null + onUpdateMyPushSettings = null + onGetConnectedAccounts = null + } diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt similarity index 96% rename from app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt rename to feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt index aae8df3c8b..2290395309 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt @@ -1,18 +1,12 @@ -package com.afternote.afternote_fe +package com.afternote.feature.mindrecord.presentation -import androidx.compose.ui.graphics.asAndroidBitmap import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.captureToImage import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasText -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.afternote.afternote_fe.test.FailureArtifactRule -import com.afternote.afternote_fe.test.FakeErrorReporter import com.afternote.core.domain.testing.FakeUserRepository import com.afternote.core.model.user.User import com.afternote.core.ui.theme.AfternoteTheme @@ -38,6 +32,7 @@ import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepositor import com.afternote.feature.mindrecord.domain.testing.unexpectedCall import com.afternote.feature.mindrecord.presentation.model.DayBackground import com.afternote.feature.mindrecord.presentation.model.DayContent +import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import com.afternote.feature.mindrecord.presentation.screen.receiver.ReceiverMindRecordScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScreen @@ -59,21 +54,20 @@ import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode import java.time.DayOfWeek import java.time.LocalDate import java.time.YearMonth -@RunWith(AndroidJUnit4::class) -class MindRecordLifecycleAndroidTest { - @get:Rule(order = 0) +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +@Config(sdk = [35], qualifiers = "w360dp-h800dp-xhdpi") +class MindRecordLifecycleTest { + @get:Rule val composeRule = createComposeRule() - @get:Rule(order = 1) - val failureArtifactRule = - FailureArtifactRule { - composeRule.onRoot().captureToImage().asAndroidBitmap() - } - @Test fun diaryList_excludesDraftChangesMonthAndReloadsAfterDelete() { val currentMonth = YearMonth.now() @@ -162,7 +156,7 @@ class MindRecordLifecycleAndroidTest { loader = MindRecordDraftLoader(diaryRepository, dailyQuestionRepository), diaryRepository = diaryRepository, dailyQuestionRepository = dailyQuestionRepository, - errorReporter = FakeErrorReporter(), + errorReporter = RecordingErrorReporter(), ) composeRule.setContent { @@ -302,7 +296,7 @@ class MindRecordLifecycleAndroidTest { ), ), ) - val viewModel = ReceiverMindRecordViewModel(repository, FakeErrorReporter()) + val viewModel = ReceiverMindRecordViewModel(repository, RecordingErrorReporter()) composeRule.setContent { AfternoteTheme { @@ -338,6 +332,10 @@ class MindRecordLifecycleAndroidTest { .onNode(hasText("2026-08-10 - 2026-08-31") and hasClickAction()) .performClick() composeRule.onNodeWithText("초기화").performClick() + composeRule.waitUntil(timeoutMillis = TIMEOUT) { + (viewModel.uiState.value as? ReceiverMindRecordUiState.Success)?.filter?.isApplied == false + } + composeRule.waitForIdle() composeRule.onNodeWithText("2026-08-10 - 2026-08-31").assertDoesNotExist() composeRule.onNodeWithText("범위 밖 일기").assertIsDisplayed() composeRule.onNodeWithText("데일리 질문").performClick()