From 749b7b4787f3e8600fcf870ffa3cb25dd95f8b13 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 1 Sep 2026 19:42:03 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor(mindrecord):=20=EC=9E=84=EC=8B=9C?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=A1=B0=ED=9A=8C=C2=B7=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=20=EC=82=AD=EC=A0=9C=EB=A5=BC=20UseCase=20=EA=B2=BD=EA=B3=84?= =?UTF-8?q?=EB=A1=9C=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 #1693 1단계. 두 정책 다 「다중 Repository 를 조율하고 부분 실패를 판정하는」 일인데, 하나는 이름만 Loader 였고 하나는 ViewModel private 함수에 UI 상태 갱신과 섞여 있었다. **조회** — `MindRecordDraftLoader` → `LoadMindRecordDraftsUseCase`. 옮기면서 「이번 달」을 인자로 뺐다. 종전에는 `YearMonth.now()` 를 구현이 직접 불러 **어느 달을 요청했는지 벽시계 없이 단언할 수 없었다**: 기대값을 만들려면 테스트도 같은 `now()` 를 불러야 해서, 구현이 지난 달을 조회하도록 잘못 바뀌어도 기대가 함께 틀려 통과한다. 기본값이 있어 호출부는 그대로다. **삭제** — `DeleteMindRecordDraftsUseCase` 신설. 병렬 삭제·부분 실패·재조회 대조를 가져간다. 핵심은 삭제 호출이 아니라 **「무엇을 실패로 칠 것인가」** 다: 실패했어도 재조회에서 사라진 항목은 실패가 아니다(이미 없는 것을 지우려다 난 404). 그것까지 올리면 화면이 「목록은 비었는데 1개 선택」 이 된다. 대상 식별자에 종류를 함께 실었다. 두 저장소의 id 는 독립이라 숫자가 겹치는데, 종전 대조는 `category to id` 쌍을 쓰고 있었지만 그 규약이 코드에 적혀 있지 않았다 — 타입으로 못 박는다. 계측은 옮기지 않았다. 실패 원인을 값으로 올리고 기록은 호출부에 남긴다 — 계측은 「무엇을 어느 화면에서」 라는 맥락과 함께여야 의미가 있다 (#964). 다만 화면에 안 보이는 실패(사라진 항목의 404)까지 기록하도록 넓혔다 — 그쪽일수록 계측이 유일한 흔적이다. 가드 14건 신설. 정책을 하나씩 되돌려 6개 방향으로 역검증했다: 재대조 제거(1) · 재조회 실패 흡수(1) · 숫자 id 만으로 대조(1) · 순차 삭제(1) · 조회 달 고정(2) · 데일리질문에 date 부착(1). Co-Authored-By: Claude Opus 5 (1M context) --- .../DailyQuestionResumeFailureAndroidTest.kt | 6 +- ...meLetterMindRecordCompletionAndroidTest.kt | 12 +- .../usecase/DeleteMindRecordDraftsUseCase.kt | 109 +++++++++ .../LoadMindRecordDraftsUseCase.kt} | 19 +- .../viewmodel/DailyQuestionWriteViewModel.kt | 3 +- .../viewmodel/DiaryWriteViewModel.kt | 3 +- .../viewmodel/DraftListViewModel.kt | 125 +++++----- .../presentation/MindRecordFlowTest.kt | 6 +- .../presentation/MindRecordLifecycleTest.kt | 8 +- .../DeleteMindRecordDraftsUseCaseTest.kt | 197 ++++++++++++++++ .../LoadMindRecordDraftsUseCaseTest.kt | 214 ++++++++++++++++++ .../viewmodel/DailyQuestionEditTest.kt | 3 +- .../viewmodel/DailyQuestionResumeDraftTest.kt | 5 +- .../DailyQuestionWriteViewModelTest.kt | 3 +- .../viewmodel/DiaryWriteImageKeyTest.kt | 3 +- .../viewmodel/DraftListDeleteTest.kt | 17 +- .../MindRecordFailureRecoveryTest.kt | 3 +- .../viewmodel/ReceiverLoadFailureTest.kt | 7 +- .../viewmodel/WriteFailureReportingTest.kt | 7 +- 19 files changed, 654 insertions(+), 96 deletions(-) create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt rename feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/{viewmodel/MindRecordDraftLoader.kt => usecase/LoadMindRecordDraftsUseCase.kt} (70%) create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCaseTest.kt diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/DailyQuestionResumeFailureAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/DailyQuestionResumeFailureAndroidTest.kt index c39be79572..277258e07a 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/DailyQuestionResumeFailureAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/DailyQuestionResumeFailureAndroidTest.kt @@ -24,8 +24,8 @@ import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionReposito import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -66,7 +66,7 @@ class DailyQuestionResumeFailureAndroidTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = FakePhotoUploadRepository.strict(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), // #964 텔레메트리가 필수 인자로 들어왔다. 이 테스트는 기록 내용을 보지 않으므로 // 받아만 두는 fake 를 넘긴다. errorReporter = FakeErrorReporter(), @@ -120,7 +120,7 @@ class DailyQuestionResumeFailureAndroidTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = FakePhotoUploadRepository.strict(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), // #964 텔레메트리가 필수 인자로 들어왔다. 이 테스트는 기록 내용을 보지 않으므로 // 받아만 두는 fake 를 넘긴다. 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 936444c658..a925a27c03 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -60,6 +60,8 @@ import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestion import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.WeeklyReportScreen +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel @@ -67,7 +69,6 @@ import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewMod import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceUiState import com.afternote.feature.mindrecord.presentation.viewmodel.MemorySpaceViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.SubmitState import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportUiState import com.afternote.feature.mindrecord.presentation.viewmodel.WeeklyReportViewModel @@ -459,7 +460,7 @@ class TimeLetterMindRecordCompletionAndroidTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = FakePhotoUploadRepository.strict(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), errorReporter = FakeErrorReporter(), ) } @@ -515,9 +516,8 @@ class TimeLetterMindRecordCompletionAndroidTest { val draftDailyQuestionRepository = FakeDailyQuestionRepository(today = completionToday()) val draftListViewModel = DraftListViewModel( - loader = MindRecordDraftLoader(repository, draftDailyQuestionRepository), - diaryRepository = repository, - dailyQuestionRepository = draftDailyQuestionRepository, + loadDrafts = LoadMindRecordDraftsUseCase(repository, draftDailyQuestionRepository), + deleteDrafts = DeleteMindRecordDraftsUseCase(repository, draftDailyQuestionRepository), errorReporter = FakeErrorReporter(), ) var routedArguments: Pair? = null @@ -546,7 +546,7 @@ class TimeLetterMindRecordCompletionAndroidTest { photoUploadRepository = FakePhotoUploadRepository.strict(), userRepository = appTestUserRepository(), draftLoader = - MindRecordDraftLoader(repository, draftDailyQuestionRepository), + LoadMindRecordDraftsUseCase(repository, draftDailyQuestionRepository), errorReporter = FakeErrorReporter(), ) }, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt new file mode 100644 index 0000000000..c07d32b9c5 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt @@ -0,0 +1,109 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import javax.inject.Inject + +/** + * 임시저장 **일괄 삭제 정책** (#1693). + * + * 종전에는 `DraftListViewModel` 안에 있었다. UI 상태 갱신과 뒤섞여 있었지만 실제로 하는 일은 + * 두 저장소에 걸친 **파괴적 동작의 조율**이다 — 되돌릴 수 없고, 부분 실패가 정상 경로이며, + * 어느 항목이 실패했는지를 «지금도 남아 있는가» 로 다시 판정해야 한다. + * + * ### 왜 실패 목록을 그대로 돌려주지 않는가 + * + * 실패했지만 **재조회에서도 사라진 항목은 실패로 치지 않는다.** 이미 없는 것을 지우려다 404 가 + * 난 경우가 여기다 — 사용자가 원한 결과는 이뤄졌고, 남아 있지도 않은 항목을 「다시 선택해 + * 주세요」 라고 하면 「목록은 비었는데 1개 선택」 같은 상태가 된다. + * + * 그래서 삭제 결과만으로 끝내지 않고 **재조회 결과와 대조**한 뒤 판정한다. 이 대조가 이 + * 동작의 핵심이고, 삭제 호출 자체는 그 재료일 뿐이다. + * + * ### 계측을 여기서 하지 않는 이유 + * + * 실패 원인([Outcome.Failure.cause])을 값으로 실어 올리고 기록은 호출부에 맡긴다. 계측은 + * 「무엇을 어느 화면에서」 라는 UI 맥락과 함께 남아야 의미가 있다 (#964 · #1693). + */ +class DeleteMindRecordDraftsUseCase + @Inject + constructor( + private val diaryRepository: DiaryRepository, + private val dailyQuestionRepository: DailyQuestionRepository, + ) { + /** + * [targets] 를 **병렬로** 지운 뒤, 실패한 것 중 아직 남아 있는 것만 골라 돌려준다. + * + * @param targets 지울 대상. 비어 있으면 아무 것도 하지 않고 [Outcome.Deleted] 를 돌린다. + * @param survivorsAfterDelete 삭제 뒤 다시 조회한 목록의 키. 재조회 자체가 실패했으면 + * `null` — 그때는 무엇이 남았는지 알 수 없어 대조할 수 없으므로 [Outcome.Unknown] 이다. + */ + suspend fun delete( + targets: List, + survivorsAfterDelete: suspend () -> Set?, + ): Outcome { + if (targets.isEmpty()) return Outcome.Deleted(failures = emptyList()) + + // 항목별 결과를 항목과 짝지어 받는다 — 무엇이 실패했는지 알아야 다시 선택해 줄 수 있다. + val results = + coroutineScope { + targets.map { target -> async { target to deleteOne(target) } }.awaitAll() + } + val failures = + results.mapNotNull { (target, result) -> + result.exceptionOrNull()?.let { Failure(target = target, cause = it) } + } + + val survivors = survivorsAfterDelete() ?: return Outcome.Unknown(failures = failures) + return Outcome.Deleted(failures = failures.filter { it.target in survivors }) + } + + private suspend fun deleteOne(target: Target): Result = + when (target.category) { + Category.Diary -> diaryRepository.delete(target.id) + Category.DailyQuestion -> dailyQuestionRepository.delete(target.id) + } + + /** + * 지울 대상의 **안정된 식별자**. + * + * 두 저장소의 id 는 서로 독립이라 숫자가 겹칠 수 있다 — 종류를 함께 들지 않으면 + * 일기 3번과 데일리질문 3번이 같은 항목으로 취급된다. + */ + data class Target( + val category: Category, + val id: Long, + ) + + /** 실제로 지울 수 있는 종류. 화면의 «전체» 필터 라벨은 여기 없다 — 항목이 아니라 필터다. */ + enum class Category { Diary, DailyQuestion } + + data class Failure( + val target: Target, + val cause: Throwable, + ) + + sealed interface Outcome { + /** 모든 실패는 [Failure] 로 실려 온다. 계측은 [Outcome.Deleted] · [Outcome.Unknown] 양쪽에서 같다. */ + val failures: List + + /** + * 삭제와 재조회가 모두 끝났다. [failures] 는 **아직 남아 있는** 실패만 담는다 — + * 비어 있으면 사용자가 원한 결과가 전부 이뤄졌다는 뜻이다. + */ + data class Deleted( + override val failures: List, + ) : Outcome + + /** + * 재조회가 실패해 무엇이 남았는지 모른다. 목록을 그릴 수 없으므로 화면은 오류로 간다. + * 그래도 [failures] 는 계측을 위해 그대로 싣는다. + */ + data class Unknown( + override val failures: List, + ) : Outcome + } + } diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCase.kt similarity index 70% rename from feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt rename to feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCase.kt index 2ec06fe2cb..78f38cf33e 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordDraftLoader.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCase.kt @@ -1,4 +1,4 @@ -package com.afternote.feature.mindrecord.presentation.viewmodel +package com.afternote.feature.mindrecord.presentation.usecase import com.afternote.core.common.result.runCatchingCancellable import com.afternote.feature.mindrecord.domain.model.DailyQuestion @@ -11,16 +11,16 @@ import java.time.YearMonth import javax.inject.Inject /** - * 임시저장의 **조회 범위 정본**. + * 임시저장의 **조회 범위 정본** (#769 · #1693). * * 임시저장 목록 화면과 작성 툴바의 카운트가 같은 범위를 봐야 한다 — 목록에 5건이 뜨는데 * 툴바가 3을 보이면 어느 쪽이 맞는지 알 수 없다. 두 화면이 각자 조회하면 범위가 갈라지므로 - * 여기 한 곳에만 둔다 (#769). + * 여기 한 곳에만 둔다. * * 일기는 `yearMonth` 가 필수라 **이번 달**만, 데일리질문은 `date` 를 생략해 **전체 기간**을 * 받는다. 서버 계약이 그렇게 갈려 있어 범위가 비대칭인 것이지 의도한 구분은 아니다. */ -class MindRecordDraftLoader +class LoadMindRecordDraftsUseCase @Inject constructor( private val diaryRepository: DiaryRepository, @@ -31,14 +31,19 @@ class MindRecordDraftLoader * * 한쪽이라도 조회에 실패하면 실패로 올린다 — 빈 목록으로 흡수하면 "실패" 와 "0건" 이 * 같은 화면이 되어 복구 수단이 사라진다. + * + * @param currentMonth 일기를 조회할 달. 종전에는 [YearMonth.now] 를 이 안에서 직접 + * 불렀는데, 그러면 **어느 달을 요청했는가를 벽시계 없이 단언할 수 없다** — 기대값을 + * 만들려고 테스트가 같은 `now()` 를 다시 부르면 구현과 기대가 같은 실수를 공유하고, + * 달이 바뀌는 자정에는 판정 자체가 흔들린다. 기본값이 있어 호출부는 종전 그대로다 (#1693). */ - suspend fun load(): Result = + suspend fun load(currentMonth: YearMonth = YearMonth.now()): Result = runCatchingCancellable { coroutineScope { val diariesDeferred = async { diaryRepository - .getList(yearMonth = YearMonth.now().toString(), draftOnly = true) + .getList(yearMonth = currentMonth.toString(), draftOnly = true) .getOrThrow() .diaries } @@ -54,7 +59,7 @@ class MindRecordDraftLoader } /** 툴바 카운트용 — 개수만 필요할 때. */ - suspend fun count(): Result = load().map { it.total } + suspend fun count(currentMonth: YearMonth = YearMonth.now()): Result = load(currentMonth).map { it.total } data class Drafts( val diaries: List, 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 bf60eb3420..84f171f5df 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 @@ -15,6 +15,7 @@ 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.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.util.isHtmlBlank import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel @@ -33,7 +34,7 @@ class DailyQuestionWriteViewModel savedStateHandle: SavedStateHandle, private val repository: DailyQuestionRepository, private val photoUploadRepository: PhotoUploadRepository, - private val draftLoader: MindRecordDraftLoader, + private val draftLoader: LoadMindRecordDraftsUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { private val _uiState = MutableStateFlow(DailyQuestionWriteUiState()) 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 9d77d2118f..6ca6a297e6 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 @@ -19,6 +19,7 @@ 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.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.util.toWireContent import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow @@ -46,7 +47,7 @@ class DiaryWriteViewModel private val repository: DiaryRepository, private val photoUploadRepository: PhotoUploadRepository, private val userRepository: UserRepository, - private val draftLoader: MindRecordDraftLoader, + private val draftLoader: LoadMindRecordDraftsUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { private val route = savedStateHandle.toRoute() diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index 1c5753168f..c5ef2ba1d5 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -10,6 +10,8 @@ import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.mapper.toUi import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage import com.afternote.feature.mindrecord.presentation.reporting.recordMindRecordFailure +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -25,16 +27,15 @@ import javax.inject.Inject /** * 임시저장 목록 ViewModel. * - * 조회 범위는 [MindRecordDraftLoader] 가 정본으로 들고 있다 — 작성 툴바의 카운트가 같은 범위를 + * 조회 범위는 [LoadMindRecordDraftsUseCase] 가 정본으로 들고 있다 — 작성 툴바의 카운트가 같은 범위를 * 봐야 목록 건수와 어긋나지 않는다 (#769). */ @HiltViewModel class DraftListViewModel @Inject constructor( - private val loader: MindRecordDraftLoader, - private val diaryRepository: DiaryRepository, - private val dailyQuestionRepository: DailyQuestionRepository, + private val loadDrafts: LoadMindRecordDraftsUseCase, + private val deleteDrafts: DeleteMindRecordDraftsUseCase, private val errorReporter: ErrorReporter, ) : ViewModel() { private val _uiState = MutableStateFlow(DraftListUiState.Loading) @@ -71,58 +72,50 @@ class DraftListViewModel _uiState.update { if (it is DraftListUiState.Success) it.copy(isDeleting = true) else it } - // 항목별 결과를 항목과 짝지어 받는다 — 무엇이 실패했는지 알아야 다시 선택해 줄 수 있다. - val results = - coroutineScope { - items - .map { item -> async { item to deleteOne(item) } } - .awaitAll() - } - // 되돌릴 수 없는 동작이고, 부분 실패는 목록과 서버 상태를 어긋나게 둔다 — - // 화면은 사용자에게 알리지만 콘솔에는 아무 흔적도 남지 않았다 (#964). - // 계측과 «실패 목록» 을 한 순회에 둔다 — 갈라 두면 나중에 한쪽만 고친다. - val failed = - results - .onEach { (_, result) -> - result.exceptionOrNull()?.let { throwable -> - errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_DELETE, throwable) - } - }.filter { (_, result) -> result.isFailure } - .map { (item, _) -> item } - - val refreshed = collectDrafts() - _uiState.value = - if (refreshed != null) { - // 실패했지만 재조회에서도 사라진 항목은 알리지 않는다 — 이미 없는 것을 지우려다 - // 404 가 난 경우가 여기다. 사용자가 원한 결과는 이뤄졌고, 남아 있지도 않은 항목을 - // 다시 선택해 주면 "목록은 비었는데 1개 선택" 같은 상태가 된다. - val remainingKeys = refreshed.mapTo(mutableSetOf()) { it.category to it.id } - val actionableFailures = failed.filter { (it.category to it.id) in remainingKeys } - - DraftListUiState.Success( - items = refreshed, - deleteOutcome = - if (actionableFailures.isEmpty()) { - DraftDeleteOutcome.AllDeleted - } else { - DraftDeleteOutcome.SomeFailed(actionableFailures) - }, - ) - } else { - DraftListUiState.Error(UiText.Resource(R.string.mindrecord_error_generic)) - } - } - } - private suspend fun deleteOne(item: DraftItem): Result = - when (item.category) { - DraftCategory.Diary -> diaryRepository.delete(item.id) + // «무엇을 지웠는지·무엇이 남았는지» 판정은 [DeleteMindRecordDraftsUseCase] 가 갖는다. + // 여기 남는 것은 「그 판정을 화면의 어떤 상태로 보일까」 뿐이다 (#1693). + var refreshed: List? = null + val outcome = + deleteDrafts.delete( + targets = items.mapNotNull { it.toDeleteTarget() }, + survivorsAfterDelete = { + refreshed = collectDrafts() + refreshed?.mapNotNullTo(mutableSetOf()) { it.toDeleteTarget() } + }, + ) + + // 되돌릴 수 없는 동작이라 실패는 콘솔에도 남겨야 한다 — 화면만 알리고 끝내면 + // 나중에 「왜 안 지워졌나」를 물을 곳이 없다 (#964). 화면에 안 보이는 실패 + // (이미 사라진 항목의 404)까지 기록한다 — 그쪽일수록 계측이 유일한 흔적이다. + outcome.failures.forEach { failure -> + errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_DELETE, failure.cause) + } - DraftCategory.DailyQuestion -> dailyQuestionRepository.delete(item.id) + val loaded = refreshed + _uiState.value = + when (outcome) { + is DeleteMindRecordDraftsUseCase.Outcome.Deleted -> { + DraftListUiState.Success( + items = loaded.orEmpty(), + deleteOutcome = + if (outcome.failures.isEmpty()) { + DraftDeleteOutcome.AllDeleted + } else { + DraftDeleteOutcome.SomeFailed( + outcome.failures.mapNotNull { failure -> failure.target.toDraftItem(loaded) }, + ) + }, + ) + } - // All 은 필터 라벨용 — 실제 항목 카테고리로는 등장하지 않는다. - DraftCategory.All -> Result.success(Unit) + // 재조회가 실패해 무엇이 남았는지 모른다 — 목록을 그릴 수 없다. + is DeleteMindRecordDraftsUseCase.Outcome.Unknown -> { + DraftListUiState.Error(UiText.Resource(R.string.mindrecord_error_generic)) + } + } } + } fun deleteAll() { val current = _uiState.value as? DraftListUiState.Success ?: return @@ -155,7 +148,7 @@ class DraftListViewModel } private suspend fun collectDrafts(): List? = - loader + loadDrafts .load() // 사용자가 «쓰다 만 글» 을 찾으러 들어오는 화면이라, 실패하면 작성물이 사라진 // 것처럼 보인다 — #519 가 실제로 그 형태의 결함이었다 (#964). @@ -188,4 +181,30 @@ class DraftListViewModel } (diaryItems + dailyQuestionItems).sortedByDescending { it.date } }.getOrNull() + + /** 화면 항목 → 삭제 대상. «전체» 는 필터 라벨이라 지울 대상이 아니다. */ + private fun DraftItem.toDeleteTarget(): DeleteMindRecordDraftsUseCase.Target? = + when (category) { + DraftCategory.Diary -> { + DeleteMindRecordDraftsUseCase.Target(DeleteMindRecordDraftsUseCase.Category.Diary, id) + } + + DraftCategory.DailyQuestion -> { + DeleteMindRecordDraftsUseCase.Target(DeleteMindRecordDraftsUseCase.Category.DailyQuestion, id) + } + + DraftCategory.All -> { + null + } + } + + /** + * 실패한 대상을 화면이 다시 선택해 줄 수 있는 항목으로 되돌린다. + * + * 재조회 목록에서 같은 키를 찾는다 — 그쪽이 서버가 방금 돌려준 최신 제목·본문이다. + * UseCase 가 «아직 남아 있는» 실패만 올리므로 못 찾는 경우는 없지만, 그렇더라도 + * 목록 자체는 그려야 하므로 그 항목만 조용히 뺀다. + */ + private fun DeleteMindRecordDraftsUseCase.Target.toDraftItem(refreshed: List?): DraftItem? = + refreshed.orEmpty().firstOrNull { it.toDeleteTarget() == this } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt index 58315040b8..ecae28d6fd 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordFlowTest.kt @@ -17,9 +17,9 @@ 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.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.SubmitState import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -133,7 +133,7 @@ class MindRecordFlowTest { uploadedUrl = "https://cdn.test/question.jpg", uploadedKey = "mindrecords/1/question.jpg", ), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), errorReporter = reporter, ) // 실제 작성 화면을 띄운다 — 빈 테마만 compose 하고 submit() 을 직접 부르면 화면과 @@ -188,7 +188,7 @@ class MindRecordFlowTest { uploadedKey = "mindrecords/1/image.jpg", ), userRepository = mindRecordFlowUserRepository(), - draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), + draftLoader = LoadMindRecordDraftsUseCase(repository, FakeDailyQuestionRepository()), errorReporter = reporter, ) } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt index 3bd5ae81ec..2549f679ed 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt @@ -38,11 +38,12 @@ import com.afternote.feature.mindrecord.presentation.screen.receiver.ReceiverMin import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.WeeklyReportScreen +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListViewModel -import com.afternote.feature.mindrecord.presentation.viewmodel.MindRecordDraftLoader import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordFilter import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordUiState import com.afternote.feature.mindrecord.presentation.viewmodel.ReceiverMindRecordViewModel @@ -154,9 +155,8 @@ class MindRecordLifecycleTest { ) val viewModel = DraftListViewModel( - loader = MindRecordDraftLoader(diaryRepository, dailyQuestionRepository), - diaryRepository = diaryRepository, - dailyQuestionRepository = dailyQuestionRepository, + loadDrafts = LoadMindRecordDraftsUseCase(diaryRepository, dailyQuestionRepository), + deleteDrafts = DeleteMindRecordDraftsUseCase(diaryRepository, dailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt new file mode 100644 index 0000000000..c3a130cc6f --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt @@ -0,0 +1,197 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase.Category +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase.Outcome +import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase.Target +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException + +/** + * 임시저장 **일괄 삭제 정책** 가드 (#1693). + * + * 되돌릴 수 없는 동작이고 부분 실패가 정상 경로라, 「무엇을 실패로 칠 것인가」가 이 동작의 + * 핵심이다. 그 판정만 여기서 본다 — 화면이 그것을 어떤 문구로 보이는지는 ViewModel 몫이다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class DeleteMindRecordDraftsUseCaseTest { + @Test + fun `두 저장소에 걸친 대상을 각각의 저장소로 보낸다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + val dailyQuestionRepository = FakeDailyQuestionRepository() + + val outcome = + DeleteMindRecordDraftsUseCase(diaryRepository, dailyQuestionRepository) + .delete( + targets = listOf(Target(Category.Diary, 1), Target(Category.DailyQuestion, 2)), + survivorsAfterDelete = { emptySet() }, + ) + + assertEquals(listOf(1L), diaryRepository.deletedIds) + assertEquals(listOf(2L), dailyQuestionRepository.deletedIds) + assertEquals(Outcome.Deleted(failures = emptyList()), outcome) + } + + /** + * **이 UseCase 의 존재 이유.** 지우려던 것이 이미 없어서 실패한 경우(404)는 사용자가 원한 + * 결과가 이뤄진 것이다. 그것까지 실패로 올리면 화면이 「목록은 비었는데 1개 선택」 이 된다. + */ + @Test + fun `실패했어도 재조회에서 사라졌으면 실패로 치지 않는다`() = + runTest { + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(IOException("이미 없음")) }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ) + + val outcome = + useCase.delete( + targets = listOf(Target(Category.Diary, 1)), + // 재조회 결과에 없다 = 서버에서도 사라졌다. + survivorsAfterDelete = { emptySet() }, + ) + + assertEquals(Outcome.Deleted(failures = emptyList()), outcome) + } + + /** 반대로 **아직 남아 있는** 실패는 올려야 한다 — 그래야 다시 선택해 줄 수 있다. */ + @Test + fun `실패하고 재조회에도 남아 있으면 실패로 올린다`() = + runTest { + val boom = IOException("서버 거절") + val target = Target(Category.Diary, 1) + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(boom) }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ) + + val outcome = useCase.delete(targets = listOf(target), survivorsAfterDelete = { setOf(target) }) + + assertEquals(listOf(target), outcome.failures.map { it.target }) + assertEquals(boom, outcome.failures.single().cause) + assertTrue(outcome is Outcome.Deleted) + } + + /** + * 두 저장소의 id 는 서로 독립이라 숫자가 겹친다. 종류를 안 들고 **숫자만으로** 대조하면 + * 「사라진 데일리질문 3」의 실패가 「남아 있는 일기 3」 때문에 살아남는다 — 사용자는 + * 자기가 지운 것을 「실패했으니 다시 선택하라」는 말로 돌려받는다. + * + * 그래서 실패한 쪽은 사라지고 **같은 숫자의 다른 종류만 남은** 상황으로 판정한다. + * 숫자만 보는 구현에서는 이 단언이 깨진다. + */ + @Test + fun `같은 숫자 id 라도 종류가 다르면 다른 항목이다`() = + runTest { + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(), + dailyQuestionRepository = + FakeDailyQuestionRepository(onDelete = { Result.failure(IOException("이미 없음")) }), + ) + + val outcome = + useCase.delete( + targets = listOf(Target(Category.DailyQuestion, 3)), + // 실패한 데일리질문 3은 사라졌고, 지우지 않은 일기 3이 남아 있다. + survivorsAfterDelete = { setOf(Target(Category.Diary, 3)) }, + ) + + assertEquals( + "사라진 데일리질문 3이 남아 있는 일기 3 때문에 실패로 살아남았다", + emptyList(), + outcome.failures.map { it.target }, + ) + } + + /** + * 재조회가 실패하면 **무엇이 남았는지 알 수 없다.** 그때 「전부 지워졌다」로 닫으면 + * 서버에 남은 것을 없다고 말하게 된다. + */ + @Test + fun `재조회가 실패하면 판정하지 않고 Unknown 을 올린다`() = + runTest { + val boom = IOException("삭제 실패") + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(boom) }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ) + + val outcome = + useCase.delete( + targets = listOf(Target(Category.Diary, 1)), + survivorsAfterDelete = { null }, + ) + + assertTrue("재조회 실패인데 $outcome 로 닫혔다", outcome is Outcome.Unknown) + // 대조를 못 해도 계측할 실패는 그대로 실어 올린다. + assertEquals(listOf(boom), outcome.failures.map { it.cause }) + } + + /** 지울 것이 없으면 저장소를 부르지 않는다 — 부르면 빈 재조회가 한 번 더 나간다. */ + @Test + fun `대상이 비면 아무 것도 지우지 않는다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + var refreshed = false + + val outcome = + DeleteMindRecordDraftsUseCase(diaryRepository, FakeDailyQuestionRepository()) + .delete(targets = emptyList(), survivorsAfterDelete = { + refreshed = true + emptySet() + }) + + assertEquals(Outcome.Deleted(failures = emptyList()), outcome) + assertEquals(emptyList(), diaryRepository.deletedIds) + assertTrue("대상이 없는데 재조회가 나갔다", !refreshed) + } + + /** 순차 삭제면 100건이 100배 걸린다 — 실제로 겹치는지 본다. */ + @Test + fun `여러 대상을 병렬로 지운다`() = + runTest { + val diaryStarted = CompletableDeferred() + val dailyQuestionStarted = CompletableDeferred() + val useCase = + DeleteMindRecordDraftsUseCase( + diaryRepository = + FakeDiaryRepository(onDelete = { + diaryStarted.complete(Unit) + // 상대가 시작하지 않으면 여기서 멈춘다 — 순차 실행이면 교착이다. + dailyQuestionStarted.await() + Result.success(Unit) + }), + dailyQuestionRepository = + FakeDailyQuestionRepository(onDelete = { + dailyQuestionStarted.complete(Unit) + diaryStarted.await() + Result.success(Unit) + }), + ) + + val outcome = + async { + useCase.delete( + targets = listOf(Target(Category.Diary, 1), Target(Category.DailyQuestion, 2)), + survivorsAfterDelete = { emptySet() }, + ) + } + advanceUntilIdle() + + assertTrue("두 삭제가 겹치지 않았다", outcome.isCompleted) + assertEquals(Outcome.Deleted(failures = emptyList()), outcome.await()) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCaseTest.kt new file mode 100644 index 0000000000..33868edb12 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/LoadMindRecordDraftsUseCaseTest.kt @@ -0,0 +1,214 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.Diary +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 kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.time.LocalDate +import java.time.YearMonth +import kotlin.coroutines.cancellation.CancellationException + +/** + * 임시저장 **조회 범위 정책** 가드 (#1693). + * + * 이 정책은 화면 둘(임시저장 목록·작성 툴바 카운트)이 공유하므로, 어느 한 화면의 테스트가 + * 아니라 정책 자체를 여기서 고정한다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class LoadMindRecordDraftsUseCaseTest { + /** + * 서버 계약이 갈려 범위가 비대칭이다 — 일기는 달 단위, 데일리질문은 전체 기간. + * 한쪽이 조용히 바뀌면 두 화면의 건수가 어긋나는 형태로만 드러나므로 여기서 못 박는다. + */ + @Test + fun `일기는 넘긴 달만, 데일리질문은 전체 기간을 조회한다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + val dailyQuestionRepository = FakeDailyQuestionRepository() + + LoadMindRecordDraftsUseCase(diaryRepository, dailyQuestionRepository) + .load(currentMonth = YearMonth.of(2026, 3)) + + assertEquals(listOf("2026-03"), diaryRepository.listQueries.map { it.yearMonth }) + assertEquals(listOf(true), diaryRepository.listQueries.map { it.draftOnly }) + // 데일리질문은 date 를 싣지 않는다 — 실으면 그 날짜 것만 온다. + assertEquals(listOf(null), dailyQuestionRepository.listQueries.map { it.date }) + assertEquals(listOf(true), dailyQuestionRepository.listQueries.map { it.draftOnly }) + } + + /** + * **벽시계에 기대지 않는다.** 종전에는 `YearMonth.now()` 를 구현이 직접 불러, 기대값을 + * 만들려면 테스트도 같은 `now()` 를 불러야 했다 — 구현이 «지난 달» 을 조회하도록 잘못 + * 바뀌어도 기대값이 함께 틀려 통과한다. + */ + @Test + fun `기본값은 이번 달이지만 다른 달을 넘기면 그 달을 조회한다`() = + runTest { + val diaryRepository = FakeDiaryRepository() + val useCase = LoadMindRecordDraftsUseCase(diaryRepository, FakeDailyQuestionRepository()) + + useCase.load() + useCase.load(currentMonth = YearMonth.of(2019, 12)) + + assertEquals( + listOf(YearMonth.now().toString(), "2019-12"), + diaryRepository.listQueries.map { it.yearMonth }, + ) + } + + /** 빈 목록으로 흡수하면 「실패」와 「0건」이 같은 화면이 되어 복구 수단이 사라진다. */ + @Test + fun `일기 조회가 실패하면 전체가 실패한다`() = + runTest { + val boom = IOException("일기 조회 실패") + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(onGetList = { _, _ -> Result.failure(boom) }), + dailyQuestionRepository = FakeDailyQuestionRepository(initialAnswers = listOf(draftQuestion(1))), + ) + + val result = useCase.load(currentMonth = YearMonth.of(2026, 3)) + + assertTrue(result.isFailure) + // `assertSame` 을 쓸 수 없다 — 코루틴 stack-trace 복원이 `async` 경계에서 예외를 + // `(String)` 생성자로 복제하므로 인스턴스가 달라진다. 종류와 메시지로 본다. + assertEquals(boom.message, result.exceptionOrNull()?.message) + assertTrue("$boom 이 아닌 것이 올라왔다", result.exceptionOrNull() is IOException) + } + + /** 반대쪽도 같다 — 한쪽만 막아 두면 다른 쪽 실패가 0건으로 새어 나간다. */ + @Test + fun `데일리질문 조회가 실패하면 전체가 실패한다`() = + runTest { + val boom = IOException("데일리질문 조회 실패") + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(initialDiaries = listOf(draftDiary(1))), + dailyQuestionRepository = FakeDailyQuestionRepository(onGetList = { _, _ -> Result.failure(boom) }), + ) + + val result = useCase.load(currentMonth = YearMonth.of(2026, 3)) + + assertTrue(result.isFailure) + // `assertSame` 을 쓸 수 없다 — 코루틴 stack-trace 복원이 `async` 경계에서 예외를 + // `(String)` 생성자로 복제하므로 인스턴스가 달라진다. 종류와 메시지로 본다. + assertEquals(boom.message, result.exceptionOrNull()?.message) + assertTrue("$boom 이 아닌 것이 올라왔다", result.exceptionOrNull() is IOException) + } + + /** + * 취소를 실패로 바꾸면 **호출부가 「조회 실패」 화면을 그린다** — 화면을 떠나며 취소된 + * 것뿐인데 오류가 뜬다. `runCatchingCancellable` 이 그것을 막는지 본다. + */ + @Test + fun `취소는 실패 결과로 바뀌지 않는다`() = + runTest { + val blocked = CompletableDeferred() + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = + FakeDiaryRepository(onGetList = { _, _ -> + blocked.await() + Result.success(emptyDiaryList()) + }), + dailyQuestionRepository = FakeDailyQuestionRepository(), + ) + var outcome: Result? = null + var cancelled = false + + val job = + launch { + try { + outcome = useCase.load(currentMonth = YearMonth.of(2026, 3)) + } catch (e: CancellationException) { + cancelled = true + throw e + } + } + advanceUntilIdle() + job.cancelAndJoin() + + assertTrue("취소가 CancellationException 으로 전파돼야 한다", cancelled) + assertEquals("취소가 Result 로 흡수됐다: $outcome", null, outcome) + } + + /** 두 조회가 순차면 느린 쪽이 합계 대기시간이 된다 — 병렬인지 실제로 겹쳐 본다. */ + @Test + fun `두 조회는 병렬로 나간다`() = + runTest { + val diaryStarted = CompletableDeferred() + val dailyQuestionStarted = CompletableDeferred() + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = + FakeDiaryRepository(onGetList = { _, _ -> + diaryStarted.complete(Unit) + // 상대가 시작하지 않으면 여기서 영원히 멈춘다 — 순차 실행이면 교착이다. + dailyQuestionStarted.await() + Result.success(emptyDiaryList()) + }), + dailyQuestionRepository = + FakeDailyQuestionRepository(onGetList = { _, _ -> + dailyQuestionStarted.complete(Unit) + diaryStarted.await() + Result.success(emptyList()) + }), + ) + + val result = async { useCase.load(currentMonth = YearMonth.of(2026, 3)) } + advanceUntilIdle() + + assertTrue("두 조회가 겹치지 않았다", result.isCompleted) + assertFalse(result.await().isFailure) + } + + /** 툴바 카운트는 목록과 **같은 조회**를 써야 건수가 어긋나지 않는다 (#769). */ + @Test + fun `count 는 두 종류의 합이다`() = + runTest { + val useCase = + LoadMindRecordDraftsUseCase( + diaryRepository = FakeDiaryRepository(initialDiaries = listOf(draftDiary(1), draftDiary(2))), + dailyQuestionRepository = FakeDailyQuestionRepository(initialAnswers = listOf(draftQuestion(1))), + ) + + assertEquals(3, useCase.count(currentMonth = YearMonth.of(2026, 3)).getOrNull()) + } + + private fun emptyDiaryList() = + com.afternote.feature.mindrecord.domain.model + .DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null) + + private fun draftDiary(id: Long) = + Diary( + diaryId = id, + title = "일기 $id", + content = "본문", + date = LocalDate.of(2026, 3, 4).toString(), + createdAt = LocalDate.of(2026, 3, 4).toString(), + todayMood = TodayMood.entries.first(), + isDraft = true, + ) + + private fun draftQuestion(id: Long) = + DailyQuestion( + dailyQuestionId = id, + title = "질문 $id", + content = "답변", + createdAt = LocalDate.of(2026, 3, 4).toString(), + isDraft = true, + ) +} 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 b128c9def9..39b45e5b89 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 @@ -12,6 +12,7 @@ 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 com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -158,7 +159,7 @@ class DailyQuestionEditTest { // 툴바 카운트는 이 시나리오의 관심사가 아니다 — **다른** 저장소를 넘긴다. // 같은 fake 를 넘기면 카운트 조회의 draftOnly=true 가 프리필 조회 기록에 섞여 // «어느 목록을 봤는가» 단언이 무너진다 (#769·#770). - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = EditTestEmptyDiaryRepository, dailyQuestionRepository = EditTestEmptyDailyQuestionRepository, ), 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 0843073bad..e27852387b 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 @@ -14,6 +14,7 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -119,7 +120,7 @@ class DailyQuestionResumeDraftTest { photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = "", uploadedKey = ""), // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). draftLoader = - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = NoDiaryDraftsRepository, dailyQuestionRepository = repository, ), @@ -296,7 +297,7 @@ class DailyQuestionResumeDraftTest { photoUploadRepository = FakePhotoUploadRepository(uploadedUrl = "", uploadedKey = ""), // 툴바 카운트는 이 테스트의 관심사가 아니다 — 같은 저장소를 넘겨 0건으로 둔다 (#769). draftLoader = - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = NoDiaryDraftsRepository, dailyQuestionRepository = repository, ), 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 017c69f977..3d3fe05b30 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 @@ -8,6 +8,7 @@ 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 com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -284,7 +285,7 @@ class DailyQuestionWriteViewModelTest { /** 툴바 카운트는 이 테스트의 관심사가 아니다 — 0건으로 고정한다 (#769). */ internal fun noopDraftLoader() = - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = FakeDiaryRepository.strict().apply { onGetList = { _, _ -> 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 b6b3e9d8f0..65da59ed21 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 @@ -10,6 +10,7 @@ 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 com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking @@ -132,7 +133,7 @@ class DiaryWriteImageKeyTest { uploadedKey = UPLOADED_KEY, ), userRepository = noReceiverUserRepository(), - draftLoader = MindRecordDraftLoader(RecordingDiaryRepository {}, NoDailyQuestionRepository), + draftLoader = LoadMindRecordDraftsUseCase(RecordingDiaryRepository {}, NoDailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index f51f92c62d..e45ac247cd 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -9,6 +9,8 @@ import com.afternote.feature.mindrecord.domain.repository.DiaryRepository 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.usecase.DeleteMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -118,15 +120,18 @@ class DraftListDeleteTest { errorReporter: RecordingErrorReporter = RecordingErrorReporter(), ): DraftListViewModel = DraftListViewModel( - // #769 가 목록 조회를 loader 로 뽑아낸 뒤 생성자가 3개로 늘었다. 같은 두 - // 저장소를 넘겨 종전과 같은 조회 경로를 그대로 태운다. - loader = - MindRecordDraftLoader( + // 조회와 삭제가 각각 UseCase 로 갈렸다 (#1693). 같은 두 저장소를 양쪽에 넘겨 + // 종전과 같은 경로를 그대로 태운다. + loadDrafts = + LoadMindRecordDraftsUseCase( + diaryRepository = emptyDiaryDrafts(), + dailyQuestionRepository = dailyQuestionRepository, + ), + deleteDrafts = + DeleteMindRecordDraftsUseCase( diaryRepository = emptyDiaryDrafts(), dailyQuestionRepository = dailyQuestionRepository, ), - diaryRepository = emptyDiaryDrafts(), - dailyQuestionRepository = dailyQuestionRepository, errorReporter = errorReporter, ) 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 6cc07cf1bf..ff9fb9acab 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 @@ -14,6 +14,7 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -119,7 +120,7 @@ class MindRecordFailureRecoveryTest { photoUploadRepository = FakePhotoUploadRepository(onUpload = { _, _ -> uploadGate.await() }), // 툴바 카운트는 이 테스트의 관심사가 아니다 — 빈 목록으로 고정한다 (#769). draftLoader = - MindRecordDraftLoader( + LoadMindRecordDraftsUseCase( diaryRepository = EmptyDiaryRepository, dailyQuestionRepository = repository, ), 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 cbb4224f7a..ac93bd3feb 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 @@ -12,6 +12,7 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter +import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -98,7 +99,7 @@ class ReceiverLoadFailureTest { userRepository { if (shouldFail) throw IOException("offline") else emptyList() }, - draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + draftLoader = LoadMindRecordDraftsUseCase(NoopDiaryRepository, NoopDailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() @@ -138,7 +139,7 @@ class ReceiverLoadFailureTest { emptyList() } }, - draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + draftLoader = LoadMindRecordDraftsUseCase(NoopDiaryRepository, NoopDailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) advanceUntilIdle() @@ -163,7 +164,7 @@ class ReceiverLoadFailureTest { repository = NoopDiaryRepository, photoUploadRepository = FakePhotoUploadRepository.strict(), userRepository = userRepository { failure?.let { throw it } ?: emptyList() }, - draftLoader = MindRecordDraftLoader(NoopDiaryRepository, NoopDailyQuestionRepository), + draftLoader = LoadMindRecordDraftsUseCase(NoopDiaryRepository, NoopDailyQuestionRepository), errorReporter = RecordingErrorReporter(), ) 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 148c9b8876..e74b8c8cb0 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 @@ -7,6 +7,7 @@ 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.usecase.LoadMindRecordDraftsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -96,7 +97,7 @@ class WriteFailureReportingTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = failingUpload(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), errorReporter = reporter, ) advanceUntilIdle() @@ -133,7 +134,7 @@ class WriteFailureReportingTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = failingUpload(), - draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), + draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository), errorReporter = reporter, ) advanceUntilIdle() @@ -155,7 +156,7 @@ class WriteFailureReportingTest { repository = repository, photoUploadRepository = failingUpload(), userRepository = emptyReceiverRepository(), - draftLoader = MindRecordDraftLoader(repository, FakeDailyQuestionRepository()), + draftLoader = LoadMindRecordDraftsUseCase(repository, FakeDailyQuestionRepository()), errorReporter = reporter, ) From 2e4ddd6d79f441702b2102454d147d4fa33660d4 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 1 Sep 2026 19:49:00 +0900 Subject: [PATCH 2/4] =?UTF-8?q?refactor(mindrecord):=20=EC=A3=BC=EA=B0=84?= =?UTF-8?q?=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20=EC=A1=B0=ED=9A=8C=C2=B7?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20=ED=8F=B4=EB=A7=81=EC=9D=84=20UseCase=20?= =?UTF-8?q?=EA=B2=BD=EA=B3=84=EB=A1=9C=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 #1693 2단계. `ObserveWeeklyReportUseCase` 가 리포트·프로필 병렬 조회와 감정 분석 제한 폴링을 갖고, ViewModel 에는 「그 결과를 어떤 화면 상태로 보일까」만 남는다. 폴링 규칙이 ViewModel 안에 있는 동안은 **「몇 번·언제 조회하는가」를 화면을 띄우지 않고는 볼 수 없었다.** 간격이 8초라 실시간으로 확인하면 한 번에 64초다. 이제 `runTest` 가상 시간으로 상한·중단·재개를 직접 잰다. 「이번 주」 판정 기준(`today`)도 인자로 뺐다 — `LocalDate.now()` 를 구현이 직접 부르면 「지난 주는 폴링하지 않는다」는 규칙을 벽시계 없이 검증할 수 없다. `WeeklyReport.analysisStatus` 도 정책 쪽으로 옮겼다. 폴링을 계속할지 정하는 값이라 판단이 있는 자리에 있어야 한다. 가드 7건 신설. 5개 방향으로 역검증했다: 지난 주 폴링(1) · 완료 후 계속(2) · 일시 실패에 통째 포기(1) · 조회 실패 후 폴링 시작(1) · 상한·간격 변경(1+1). 상한 테스트는 기대값을 **숫자로 적었다**. `1 + POLL_ATTEMPTS` 로 쓰면 상수를 바꿔도 기대값이 함께 움직여 어떤 값이든 통과한다 — 실제로 처음엔 그렇게 써서 역검증이 0건이었다. 기존 주간 리포트 테스트 6개 파일은 생성자만 따라 고쳤고 단언은 그대로다. 전부 통과한다. Co-Authored-By: Claude Opus 5 (1M context) --- ...meLetterMindRecordCompletionAndroidTest.kt | 3 +- .../usecase/ObserveWeeklyReportUseCase.kt | 118 +++++++++ .../viewmodel/WeeklyReportViewModel.kt | 133 ++++------- .../presentation/MindRecordLifecycleTest.kt | 3 +- .../usecase/ObserveWeeklyReportUseCaseTest.kt | 226 ++++++++++++++++++ .../viewmodel/MindRecordRequestCountTest.kt | 5 +- .../WeeklyReportEmotionAnalysisTest.kt | 3 +- .../viewmodel/WeeklyReportRecoveryTest.kt | 3 +- .../viewmodel/WeeklyReportWeekRangeTest.kt | 3 +- 9 files changed, 399 insertions(+), 98 deletions(-) create mode 100644 feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt create mode 100644 feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt 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 a925a27c03..16d4361a6c 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -62,6 +62,7 @@ import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScre import com.afternote.feature.mindrecord.presentation.screen.sender.WeeklyReportScreen import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel @@ -669,7 +670,7 @@ class TimeLetterMindRecordCompletionAndroidTest { profile = User("주간 사용자", "weekly@afternote.local", null, null), receivers = emptyList(), ) - val viewModel = WeeklyReportViewModel(repository, userRepository, MindRecordChangeTracker()) + val viewModel = WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository), MindRecordChangeTracker()) composeRule.setContent { AfternoteTheme { diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt new file mode 100644 index 0000000000..8a60384099 --- /dev/null +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt @@ -0,0 +1,118 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.core.common.result.runCatchingCancellable +import com.afternote.core.domain.repository.UserRepository +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import javax.inject.Inject + +/** + * 주간 리포트 **조회·제한 폴링 정책** (#725 · #1693). + * + * 감정 분석은 저장 직후 비동기로 돌고 **완료를 알리는 채널이 없다.** 탭에 머무는 동안 결과가 + * 반영되려면 제한된 재조회가 필요하다. 종전에는 이 규칙이 `WeeklyReportViewModel` 안에서 UI + * 상태 갱신과 뒤섞여 있어, 「몇 번·언제 조회하는가」를 화면을 띄우지 않고는 검증할 수 없었다. + * + * ### 무한 폴링을 하지 않는다 + * + * [POLL_ATTEMPTS] 회만 시도하고 그 뒤에는 화면 이탈·복귀나 사용자의 재시도에 맡긴다. + * 그리고 **이번 주에만** 돈다 — 폴링의 근거가 「저장 직후 비동기 분석」이라 지난 주를 보는 + * 동안 8초마다 조회가 나갈 이유가 없다. + * + * ### 한 번 실패했다고 남은 시도를 버리지 않는다 + * + * `PENDING` 화면에는 재시도 수단이 없어(카드는 `FAILED` 전용) 여기서 포기하면 화면에 머무는 + * 동안 복구할 길이 사라진다. 그 시도만 소모하고 다음 간격을 기다린다. + * + * ### 취소 + * + * 사용자가 다른 주를 고르면 호출부가 수집을 취소하고, 그 취소가 대기까지 함께 끊는다. + * 취소를 실패로 바꾸지 않는다 — 화면을 떠난 것뿐인데 오류가 뜨면 안 된다. + */ +class ObserveWeeklyReportUseCase + @Inject + constructor( + private val repository: WeeklyReportRepository, + private val userRepository: UserRepository, + ) { + /** + * [monday] 주의 리포트를 내보낸다. + * + * 첫 방출은 리포트·프로필 **병렬** 조회 결과다. 실패하면 그것으로 끝난다 — 조회가 + * 안 되는데 폴링할 이유가 없다. + * + * 성공했고 이번 주이고 분석이 `PENDING` 이면, 완료되거나 [POLL_ATTEMPTS] 를 소진할 + * 때까지 갱신된 리포트를 이어서 내보낸다. + * + * @param today 「이번 주」 판정 기준. 종전에는 [LocalDate.now] 를 구현이 직접 불러 + * **지난 주에는 폴링하지 않는다는 규칙을 벽시계 없이 검증할 수 없었다** (#1693). + */ + fun observe( + monday: LocalDate, + today: LocalDate = LocalDate.now(), + ): Flow> = + flow { + val first = + runCatchingCancellable { + coroutineScope { + val reportDeferred = async { repository.getWeeklyReport(date = monday.apiDate()).getOrThrow() } + val profileDeferred = async { userRepository.getMyProfile() } + Snapshot(report = reportDeferred.await(), profileName = profileDeferred.await().name) + } + } + emit(first) + + val loaded = first.getOrNull() ?: return@flow + if (monday != today.with(DayOfWeek.MONDAY)) return@flow + + var status = loaded.report.analysisStatus + repeat(POLL_ATTEMPTS) { + if (status != EmotionAnalysisStatus.PENDING) return@flow + delay(POLL_INTERVAL_MILLIS) + val report = repository.getWeeklyReport(date = monday.apiDate()).getOrNull() + currentCoroutineContext().ensureActive() + if (report == null) return@repeat + status = report.analysisStatus + emit(Result.success(loaded.copy(report = report))) + } + } + + private fun LocalDate.apiDate(): String = format(API_DATE_FORMATTER) + + /** + * 한 주의 화면 재료. 이름은 첫 조회에서만 오고 폴링은 리포트만 갱신한다 — + * 프로필은 8초마다 다시 받을 이유가 없다. + */ + data class Snapshot( + val report: WeeklyReport, + val profileName: String, + ) + + companion object { + /** 화면에 머무는 동안만 시도하는 횟수. 무한 폴링을 막는 상한이다. */ + const val POLL_ATTEMPTS = 8 + const val POLL_INTERVAL_MILLIS = 8_000L + + private val API_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + } + } + +/** + * 서버가 진행 상태를 주지 않았으면 «모른다» — 0 건으로 확정하지 않는다 (#725). + * + * 폴링을 계속할지 정하는 값이라 정책과 같은 자리에 둔다. 화면(`WeeklyReportViewModel`)도 + * 카드에 그대로 실어 보여 주므로 모듈 안에서 공유한다 (#1693). + */ +internal val WeeklyReport.analysisStatus: EmotionAnalysisStatus + get() = emotionAnalysis?.status ?: EmotionAnalysisStatus.UNKNOWN diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt index d6549132af..5fb4455841 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt @@ -21,6 +21,8 @@ import com.afternote.feature.mindrecord.presentation.model.DayContent import com.afternote.feature.mindrecord.presentation.model.DayItem import com.afternote.feature.mindrecord.presentation.model.EmotionKeyword import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase +import com.afternote.feature.mindrecord.presentation.usecase.analysisStatus import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -44,8 +46,7 @@ import javax.inject.Inject class WeeklyReportViewModel @Inject constructor( - private val repository: WeeklyReportRepository, - private val userRepository: UserRepository, + private val observeWeeklyReport: ObserveWeeklyReportUseCase, private val changeTracker: MindRecordChangeTracker, ) : ViewModel() { private val weekOptions: List = @@ -181,96 +182,50 @@ class WeeklyReportViewModel if (showsLoading) { internalState.update { it.copy(loadPhase = LoadPhase.Loading) } } - val result = - runCatchingCancellable { - coroutineScope { - val reportDeferred = - async { - repository - .getWeeklyReport(date = monday.format(API_DATE_FORMATTER)) - .getOrThrow() + // 조회·폴링 정책은 [ObserveWeeklyReportUseCase] 가 갖는다. 여기 남는 것은 + // 「그 결과를 화면의 어떤 상태로 보일까」 뿐이다 (#1693). + // + // 첫 방출은 리포트·프로필 병렬 조회 결과이고, 이번 주에 분석이 진행 중이면 + // 갱신된 리포트가 이어서 온다. 새 로드가 이 Job 을 취소하면 수집도 함께 + // 끊기므로 «다른 주로 옮겨갔는데 옛 응답이 덮어쓰는» 일이 없다. + observeWeeklyReport.observe(monday).collect { result -> + ensureActive() + result + .onSuccess { snapshot -> + loadedVersion = versionAtLoadStart + internalState.update { current -> + val phase = current.loadPhase + if (phase is LoadPhase.Loaded && phase.monday == monday) { + // 폴링 갱신 — 이름은 첫 조회 것을 그대로 둔다. + current.copy(loadPhase = phase.copy(report = snapshot.report)) + } else { + current.copy( + loadPhase = LoadPhase.Loaded(monday, snapshot.report, snapshot.profileName), + ) + } + } + }.onFailure { e -> + internalState.update { current -> + if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { + current + } else { + current.copy( + loadPhase = + LoadPhase.Failed( + message = + UiText.DynamicOrResource( + value = e.message, + fallbackResId = R.string.mindrecord_error_weekly_report_failed, + ), + monday = monday, + previous = previousLoaded, + ), + ) } - val profileDeferred = async { userRepository.getMyProfile() } - reportDeferred.await() to profileDeferred.await() - } - } - // 새 로드가 이 Job 을 취소했다면 상태는 그쪽이 결정하므로 여기서 멈춘다. - // `runCatchingCancellable` 이 취소를 다시 던지므로 위에서 이미 빠져나가지만, - // `await()` 사이에 취소가 들어온 경우를 위해 남겨 둔다. - ensureActive() - result - .onSuccess { (report, profile) -> - loadedVersion = versionAtLoadStart - internalState.update { - it.copy(loadPhase = LoadPhase.Loaded(monday, report, profile.name)) - } - awaitEmotionAnalysis(monday, report.analysisStatus) - }.onFailure { e -> - internalState.update { current -> - if (keepsStateOnFailure && current.loadPhase is LoadPhase.Loaded) { - current - } else { - current.copy( - loadPhase = - LoadPhase.Failed( - message = - UiText.DynamicOrResource( - value = e.message, - fallbackResId = R.string.mindrecord_error_weekly_report_failed, - ), - monday = monday, - previous = previousLoaded, - ), - ) } } - } - } - } - - /** - * 감정 분석이 끝나기를 화면에 머무른 채 기다린다. - * - * 분석은 저장 직후 비동기로 돌고 완료 신호를 주는 채널이 없어서, 탭에 머무르는 동안 - * 결과가 반영되려면 제한된 재조회가 필요하다 (#725). 무한 폴링은 하지 않는다 — - * [EMOTION_ANALYSIS_POLL_ATTEMPTS] 회만 시도하고, 그 뒤에는 화면 이탈·복귀 - * ([refreshOnReturn])나 사용자의 재시도에 맡긴다. - * - * 이 로직은 [load] 의 코루틴 안에서 돈다. 사용자가 다른 주를 고르면 그 `loadJob` - * 취소가 이 대기까지 함께 끊는다. - */ - private suspend fun awaitEmotionAnalysis( - monday: LocalDate, - initialStatus: EmotionAnalysisStatus, - ) { - // 폴링 근거는 "저장 직후 비동기 분석" 이라 이번 주에만 성립한다. 지난 주를 - // 골라 보는 동안 8초마다 조회가 나갈 이유가 없다. - if (monday != LocalDate.now().with(DayOfWeek.MONDAY)) return - - var status = initialStatus - repeat(EMOTION_ANALYSIS_POLL_ATTEMPTS) { - if (status != EmotionAnalysisStatus.PENDING) return - delay(EMOTION_ANALYSIS_POLL_INTERVAL_MILLIS) - val report = - repository - .getWeeklyReport(date = monday.format(API_DATE_FORMATTER)) - .getOrNull() - currentCoroutineContext().ensureActive() - // 한 번 실패했다고 남은 시도를 전부 버리지 않는다 — PENDING 화면에는 - // 재시도 수단이 없어(카드는 FAILED 전용) 화면에 머무는 동안 복구할 길이 - // 사라진다. 이번 시도만 소모하고 다음 간격을 기다린다. - if (report == null) return@repeat - status = report.analysisStatus - internalState.update { current -> - val phase = current.loadPhase - // 그 사이 다른 주로 옮겨갔으면 덮어쓰지 않는다. - if (phase is LoadPhase.Loaded && phase.monday == monday) { - current.copy(loadPhase = phase.copy(report = report)) - } else { - current } } - } } private fun buildWeekOptions( @@ -466,7 +421,3 @@ class WeeklyReportViewModel ) } } - -/** 서버가 진행 상태를 주지 않았으면 «모른다» — 0 건으로 확정하지 않는다 (#725). */ -private val WeeklyReport.analysisStatus: EmotionAnalysisStatus - get() = emotionAnalysis?.status ?: EmotionAnalysisStatus.UNKNOWN diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt index 2549f679ed..5fef3a495b 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/MindRecordLifecycleTest.kt @@ -40,6 +40,7 @@ import com.afternote.feature.mindrecord.presentation.screen.sender.DraftListScre import com.afternote.feature.mindrecord.presentation.screen.sender.WeeklyReportScreen import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListUiState import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryListViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DraftListUiState @@ -212,7 +213,7 @@ class MindRecordLifecycleTest { val repository = FakeWeeklyReportRepository() repository.results.addLast(Result.failure(IllegalStateException("weekly offline"))) val userRepository = privateProfileRepository("테스트 사용자") - val viewModel = WeeklyReportViewModel(repository, userRepository, MindRecordChangeTracker()) + val viewModel = WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository), MindRecordChangeTracker()) composeRule.setContent { AfternoteTheme { diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt new file mode 100644 index 0000000000..e6cff00dc6 --- /dev/null +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt @@ -0,0 +1,226 @@ +package com.afternote.feature.mindrecord.presentation.usecase + +import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.testing.FakeUserRepository +import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.time.DayOfWeek +import java.time.LocalDate +import kotlin.coroutines.cancellation.CancellationException + +/** + * 주간 리포트 **조회·제한 폴링 정책** 가드 (#725 · #1693). + * + * 종전에는 이 규칙이 ViewModel 안에 있어 「몇 번·언제 조회하는가」를 화면을 띄우지 않고는 + * 볼 수 없었다. 폴링은 8초 간격이라 실시간으로 기다리면 한 번에 64초다 — `runTest` 의 + * 가상 시간으로 돌린다. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ObserveWeeklyReportUseCaseTest { + private val thisMonday: LocalDate = LocalDate.of(2026, 3, 2) + private val today: LocalDate = thisMonday.plusDays(3) + + @Test + fun `첫 방출은 리포트와 프로필 이름을 함께 싣는다`() = + runTest { + val useCase = useCase(responses = listOf(Result.success(completed())), name = "채연") + + val first = useCase.observe(thisMonday, today).toList().first() + + assertEquals("채연", first.getOrNull()?.profileName) + assertEquals(completed(), first.getOrNull()?.report) + } + + /** 조회가 안 되는데 8초마다 다시 부를 이유가 없다. */ + @Test + fun `첫 조회가 실패하면 폴링하지 않고 끝난다`() = + runTest { + val repository = QueuedWeeklyReportRepository(listOf(Result.failure(IOException("조회 실패")))) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + val emissions = useCase.observe(thisMonday, today).toList() + + assertEquals(1, emissions.size) + assertTrue(emissions.single().isFailure) + assertEquals(1, repository.requestedDates.size) + } + + /** + * 분석은 완료 신호를 주는 채널이 없어 재조회가 유일한 확인 수단이다 (#725). + * 완료된 순간 **바로 멈춰야** 한다 — 남은 시도를 마저 돌면 쓸데없는 조회가 나간다. + */ + @Test + fun `분석이 끝나면 그 즉시 폴링을 멈춘다`() = + runTest { + val repository = + QueuedWeeklyReportRepository( + listOf( + Result.success(pending()), + Result.success(pending()), + Result.success(completed()), + ), + ) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + val emissions = useCase.observe(thisMonday, today).toList() + + // 첫 조회 + 폴링 2회 = 3 + assertEquals(3, repository.requestedDates.size) + assertEquals(3, emissions.size) + assertEquals(completed(), emissions.last().getOrNull()?.report) + } + + /** + * 무한 폴링을 하지 않는다 — 상한을 넘기면 화면 이탈·복귀나 재시도에 맡긴다. + * + * 기대값을 **숫자로 적는다.** `1 + POLL_ATTEMPTS` 로 쓰면 상수가 바뀔 때 기대값도 함께 + * 움직여 어떤 값이든 통과한다 — 상한을 지키는 테스트가 상한을 안 보게 된다. + * 8초 × 8회 = 64초가 「화면에 머무는 동안」의 상한이라는 판단이 여기 박혀 있어야, + * 그 값을 바꾸는 사람이 이 테스트에서 한 번 멈춘다. + */ + @Test + fun `분석이 끝나지 않아도 여덟 번에서 멈춘다`() = + runTest { + val repository = QueuedWeeklyReportRepository(alwaysPending = true) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + useCase.observe(thisMonday, today).toList() + + assertEquals("첫 조회 1회 + 폴링 8회", 9, repository.requestedDates.size) + assertEquals(8, ObserveWeeklyReportUseCase.POLL_ATTEMPTS) + assertEquals(8_000L, ObserveWeeklyReportUseCase.POLL_INTERVAL_MILLIS) + } + + /** + * 폴링의 근거는 「저장 직후 비동기 분석」이라 **이번 주에만** 성립한다. 지난 주를 보는 + * 동안 8초마다 조회가 나갈 이유가 없다. + */ + @Test + fun `지난 주는 진행 중이어도 폴링하지 않는다`() = + runTest { + val repository = QueuedWeeklyReportRepository(alwaysPending = true) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + val emissions = useCase.observe(thisMonday.minusWeeks(1), today).toList() + + assertEquals(1, repository.requestedDates.size) + assertEquals(1, emissions.size) + } + + /** + * `PENDING` 화면에는 재시도 수단이 없다(카드는 `FAILED` 전용). 한 번 실패했다고 남은 + * 시도를 버리면 **화면에 머무는 동안 복구할 길이 사라진다.** + */ + @Test + fun `폴링 중 한 번 실패해도 남은 시도를 계속한다`() = + runTest { + val repository = + QueuedWeeklyReportRepository( + listOf( + Result.success(pending()), + Result.failure(IOException("일시 실패")), + Result.success(completed()), + ), + ) + val useCase = ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + + val emissions = useCase.observe(thisMonday, today).toList() + + assertEquals(3, repository.requestedDates.size) + // 실패한 시도는 방출하지 않는다 — 화면을 오류로 덮으면 진행 중 표시가 사라진다. + assertEquals(2, emissions.size) + assertEquals(completed(), emissions.last().getOrNull()?.report) + assertTrue("일시 실패가 화면까지 올라갔다", emissions.none { it.isFailure }) + } + + /** 사용자가 다른 주를 고르면 수집이 끊긴다. 그것을 「조회 실패」로 바꾸면 안 된다. */ + @Test + fun `취소는 실패 방출로 바뀌지 않는다`() = + runTest { + val blocked = CompletableDeferred() + val repository = + object : WeeklyReportRepository { + override suspend fun getWeeklyReport(date: String): Result { + blocked.await() + return Result.success(completed()) + } + } + val emissions = mutableListOf>() + var cancelled = false + + val job = + launch { + try { + ObserveWeeklyReportUseCase(repository, FakeUserRepository()) + .observe(thisMonday, today) + .toList(emissions) + } catch (e: CancellationException) { + cancelled = true + throw e + } + } + advanceUntilIdle() + job.cancelAndJoin() + + assertTrue("취소가 CancellationException 으로 전파돼야 한다", cancelled) + assertEquals("취소가 방출로 흡수됐다: $emissions", emptyList(), emissions) + } + + private fun useCase( + responses: List>, + name: String, + ): ObserveWeeklyReportUseCase = + ObserveWeeklyReportUseCase( + repository = QueuedWeeklyReportRepository(responses), + userRepository = userRepositoryNamed(name), + ) + + private fun userRepositoryNamed(name: String): UserRepository = FakeUserRepository().apply { profile = profile.copy(name = name) } + + /** 큐를 순서대로 돌려주고, 다 쓰면 마지막 응답을 계속 준다. */ + private class QueuedWeeklyReportRepository( + private val responses: List> = emptyList(), + private val alwaysPending: Boolean = false, + ) : WeeklyReportRepository { + val requestedDates = mutableListOf() + + override suspend fun getWeeklyReport(date: String): Result { + requestedDates += date + if (alwaysPending) return Result.success(pendingReport()) + return responses[minOf(requestedDates.lastIndex, responses.lastIndex)] + } + + companion object { + fun pendingReport(): WeeklyReport = report(EmotionAnalysis(total = 2, succeeded = 0, pending = 2, failed = 0)) + } + } + + private fun pending(): WeeklyReport = QueuedWeeklyReportRepository.pendingReport() + + private fun completed(): WeeklyReport = report(EmotionAnalysis(total = 2, succeeded = 2, pending = 0, failed = 0)) + + private companion object { + fun report(analysis: EmotionAnalysis): WeeklyReport = + WeeklyReport( + dailyQuestionAmount = 0, + diaryAmount = 1, + summaryText = "요약", + week = emptyList(), + dailyQuestions = emptyList(), + emotions = emptyList(), + emotionAnalysis = analysis, + ) + } +} diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt index b1b400cb0b..0fab353982 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/MindRecordRequestCountTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -112,7 +113,7 @@ class MindRecordRequestCountTest { fun `주간리포트도 데이터가 그대로면 복귀에 다시 부르지 않는다`() = runTest(dispatcher) { val repository = CountingWeeklyReportRepository() - val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + val viewModel = WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() @@ -130,7 +131,7 @@ class MindRecordRequestCountTest { // 소진된 뒤 대기가 남으면 복귀 갱신이 유일한 복구 경로인데, «데이터가 그대로면 // 부르지 않는다» 가드가 그것까지 막으면 «분석 중» 이 앱 재시작까지 굳는다. val repository = CountingWeeklyReportRepository(pendingAnalysis = true) - val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + val viewModel = WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() advanceTimeBy(10 * 60 * 1000L) diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt index 469f4c605d..d77e2c7596 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportEmotionAnalysisTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -221,7 +222,7 @@ class WeeklyReportEmotionAnalysisTest { return Result.success(report) } } - return WeeklyReportViewModel(repository, userRepository(), changeTracker) + return WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker) } private fun userRepository(): FakeUserRepository = diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt index b6205f4b2f..138b2098b8 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt @@ -6,6 +6,7 @@ import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -177,7 +178,7 @@ class WeeklyReportRecoveryTest { ) private fun TestScope.start(repository: WeeklyReportRepository): WeeklyReportViewModel { - val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + val viewModel = WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() return viewModel diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt index a173c39b36..b3383d152c 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportWeekRangeTest.kt @@ -8,6 +8,7 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.model.MindRecordCategoryUi +import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReportUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf @@ -125,7 +126,7 @@ class WeeklyReportWeekRangeTest { object : WeeklyReportRepository { override suspend fun getWeeklyReport(date: String): Result = Result.success(report) } - val viewModel = WeeklyReportViewModel(repository, userRepository(), changeTracker) + val viewModel = WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() return viewModel.uiState.value as WeeklyReportUiState.Success From 510a0396f17dedb02100f53dd156f1d782111f77 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Thu, 3 Sep 2026 20:21:49 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix(mindrecord):=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EA=B3=84=EC=B8=A1=EC=9D=84=20=EC=A0=84?= =?UTF-8?q?=EA=B1=B4=EC=9C=BC=EB=A1=9C=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B3=A0=20=EC=9E=AC=EC=A7=84=EC=9E=85=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0=EC=9D=84=20=EA=B3=A0=EC=B9=9C=EB=8B=A4=20(#1?= =?UTF-8?q?693)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 삭제 실패 계측이 좁아져 있었다 (blocking) 종전 DraftListViewModel 은 재조회 대조 **전에** 실패 전건을 올렸는데, UseCase 로 옮기며 survivors 로 거른 뒤 VM 이 그것만 기록하게 됐다. 「이미 사라진 항목의 404」 처럼 화면에 안 보이는 실패일수록 콘솔이 유일한 흔적인데 그쪽이 통째로 사라진다. #1693 의 「#964 telemetry 를 제거하지 않는다」 위반이고, VM 주석·Outcome KDoc·PR 본문이 전부 코드와 반대를 말하고 있었다. Outcome.Deleted 에 failures(전건)와 remaining(남은 것)을 나눠 싣는다. 계측은 전건을, 화면은 remaining 을 본다. DraftListDeleteTest 의 「사라졌으면 완료로 본다」에 reporter.stages 단언을 붙여 고정했고, 성공 경로에는 계측이 없다는 반대쪽 경계도 함께 단언한다. ## 재진입 갱신이 새 프로필 이름을 버렸다 (normal) WeeklyReportViewModel 이 「이미 Loaded 이고 같은 주」면 copy(report=) 로만 갱신해 방금 받아 온 이름을 버렸다. 이 분기는 폴링만이 아니라 ON_RESUME 재조회도 탄다 — 설정에서 이름을 바꾸고 돌아오면 주를 옮기기 전까지 옛 이름이 남는다. 폴링 방출은 UseCase 가 loaded.copy(report=) 로 첫 조회의 이름을 그대로 싣고 오므로 갈라 둘 이유가 없다. 분기를 걷고 회귀를 고정했다. ## nit 3건 - Deleted 인데 재조회가 없으면 orEmpty() 대신 현 목록을 유지한다. 지금은 위 가드가 막지만 그 가드에 기대지 않는다. - POLL_ATTEMPTS·POLL_INTERVAL_MILLIS 를 private companion 으로 닫는다. 테스트는 요청 횟수와 흐른 시간(64초)으로 상한을 본다 — 프로덕션 visibility 를 테스트 때문에 넓히지 않는다 (#1688). - noopDraftLoader → noopLoadDraftsUseCase. MindRecordDraftLoader 는 사라졌다. 역검증: 계측을 remaining 으로 되돌리면 「사라졌으면 완료로 본다」가, 이름 분기를 되살리면 「재진입 갱신은 그 사이 바뀐 프로필 이름을 반영한다」가 각각 FAILED. 254건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../usecase/DeleteMindRecordDraftsUseCase.kt | 24 +++++++++--- .../usecase/ObserveWeeklyReportUseCase.kt | 2 +- .../viewmodel/DraftListViewModel.kt | 13 +++++-- .../viewmodel/WeeklyReportViewModel.kt | 17 ++++----- .../DeleteMindRecordDraftsUseCaseTest.kt | 26 +++++++++---- .../usecase/ObserveWeeklyReportUseCaseTest.kt | 5 ++- .../DailyQuestionWriteViewModelTest.kt | 18 ++++----- .../viewmodel/DraftListDeleteTest.kt | 11 +++++- .../viewmodel/WeeklyReportRecoveryTest.kt | 37 ++++++++++++++++++- 9 files changed, 111 insertions(+), 42 deletions(-) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt index c07d32b9c5..1355259cef 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt @@ -35,7 +35,7 @@ class DeleteMindRecordDraftsUseCase private val dailyQuestionRepository: DailyQuestionRepository, ) { /** - * [targets] 를 **병렬로** 지운 뒤, 실패한 것 중 아직 남아 있는 것만 골라 돌려준다. + * [targets] 를 **병렬로** 지우고, 실패 전건과 그중 아직 남아 있는 것을 나눠 돌려준다. * * @param targets 지울 대상. 비어 있으면 아무 것도 하지 않고 [Outcome.Deleted] 를 돌린다. * @param survivorsAfterDelete 삭제 뒤 다시 조회한 목록의 키. 재조회 자체가 실패했으면 @@ -45,7 +45,7 @@ class DeleteMindRecordDraftsUseCase targets: List, survivorsAfterDelete: suspend () -> Set?, ): Outcome { - if (targets.isEmpty()) return Outcome.Deleted(failures = emptyList()) + if (targets.isEmpty()) return Outcome.Deleted(failures = emptyList(), remaining = emptyList()) // 항목별 결과를 항목과 짝지어 받는다 — 무엇이 실패했는지 알아야 다시 선택해 줄 수 있다. val results = @@ -58,7 +58,10 @@ class DeleteMindRecordDraftsUseCase } val survivors = survivorsAfterDelete() ?: return Outcome.Unknown(failures = failures) - return Outcome.Deleted(failures = failures.filter { it.target in survivors }) + return Outcome.Deleted( + failures = failures, + remaining = failures.filter { it.target in survivors }, + ) } private suspend fun deleteOne(target: Target): Result = @@ -87,15 +90,24 @@ class DeleteMindRecordDraftsUseCase ) sealed interface Outcome { - /** 모든 실패는 [Failure] 로 실려 온다. 계측은 [Outcome.Deleted] · [Outcome.Unknown] 양쪽에서 같다. */ + /** + * 이번 삭제에서 난 **실패 전건**. 재조회 대조와 무관하게 그대로 싣는다. + * + * 계측이 보는 것은 이쪽이다. 「이미 사라진 항목의 404」처럼 화면에 안 보이는 실패일수록 + * 계측이 유일한 흔적이라, 대조로 걸러 내면 콘솔에서도 사라진다 (#964·#1693). + */ val failures: List /** - * 삭제와 재조회가 모두 끝났다. [failures] 는 **아직 남아 있는** 실패만 담는다 — - * 비어 있으면 사용자가 원한 결과가 전부 이뤄졌다는 뜻이다. + * 삭제와 재조회가 모두 끝났다. + * + * 화면이 쓰는 것은 [remaining] 이다 — 비어 있으면 사용자가 원한 결과가 전부 이뤄졌다. + * [failures] 와 갈리는 이유는 위 KDoc 에 있다. */ data class Deleted( override val failures: List, + /** [failures] 중 재조회 목록에 **아직 남아 있는** 것. 다시 선택해 줄 대상이다. */ + val remaining: List, ) : Outcome /** diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt index 8a60384099..15e77787cc 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCase.kt @@ -99,7 +99,7 @@ class ObserveWeeklyReportUseCase val profileName: String, ) - companion object { + private companion object { /** 화면에 머무는 동안만 시도하는 횟수. 무한 폴링을 막는 상한이다. */ const val POLL_ATTEMPTS = 8 const val POLL_INTERVAL_MILLIS = 8_000L diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index c5ef2ba1d5..df707e7eaa 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -92,18 +92,25 @@ class DraftListViewModel errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_DELETE, failure.cause) } - val loaded = refreshed + // 재조회 결과가 없으면 **현 목록을 유지한다**. UseCase 는 targets 가 비면 + // survivorsAfterDelete 를 부르지 않고 Deleted 를 돌리므로 `refreshed` 가 null 인 + // 채 여기 올 수 있다 — orEmpty() 로 두면 목록이 빈 리스트로 갈린다. 지금은 위 + // items.isEmpty() 가드가 막고 있지만 그 가드에 기대지 않는다 (#1693 리뷰). + val loaded = refreshed ?: (_uiState.value as? DraftListUiState.Success)?.items _uiState.value = when (outcome) { is DeleteMindRecordDraftsUseCase.Outcome.Deleted -> { DraftListUiState.Success( items = loaded.orEmpty(), + // 화면은 **남은 것**만 본다 — 이미 사라진 항목의 404 까지 «실패» 로 + // 보이면 사용자가 다시 지울 수 없는 것을 다시 고르게 된다. + // 계측은 위에서 전건을 이미 올렸다 (#1693). deleteOutcome = - if (outcome.failures.isEmpty()) { + if (outcome.remaining.isEmpty()) { DraftDeleteOutcome.AllDeleted } else { DraftDeleteOutcome.SomeFailed( - outcome.failures.mapNotNull { failure -> failure.target.toDraftItem(loaded) }, + outcome.remaining.mapNotNull { failure -> failure.target.toDraftItem(loaded) }, ) }, ) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt index 5fb4455841..f14c45c9e1 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt @@ -193,16 +193,15 @@ class WeeklyReportViewModel result .onSuccess { snapshot -> loadedVersion = versionAtLoadStart + // 방출이 첫 조회든 폴링이든 [Snapshot] 이 이름을 함께 싣는다 — + // 폴링은 `loaded.copy(report =)` 로 첫 조회의 이름을 그대로 들고 온다. + // 그래서 여기서 갈라 «이름은 첫 조회 것» 으로 두면, ON_RESUME + // 재조회(refreshOnReturn)가 방금 받은 새 이름을 버린다 — 설정에서 + // 이름을 바꾸고 돌아오면 주를 옮기기 전까지 옛 이름이 남는다 (#1693 리뷰). internalState.update { current -> - val phase = current.loadPhase - if (phase is LoadPhase.Loaded && phase.monday == monday) { - // 폴링 갱신 — 이름은 첫 조회 것을 그대로 둔다. - current.copy(loadPhase = phase.copy(report = snapshot.report)) - } else { - current.copy( - loadPhase = LoadPhase.Loaded(monday, snapshot.report, snapshot.profileName), - ) - } + current.copy( + loadPhase = LoadPhase.Loaded(monday, snapshot.report, snapshot.profileName), + ) } }.onFailure { e -> internalState.update { current -> diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt index c3a130cc6f..24f8cfea56 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCaseTest.kt @@ -38,19 +38,24 @@ class DeleteMindRecordDraftsUseCaseTest { assertEquals(listOf(1L), diaryRepository.deletedIds) assertEquals(listOf(2L), dailyQuestionRepository.deletedIds) - assertEquals(Outcome.Deleted(failures = emptyList()), outcome) + assertEquals(Outcome.Deleted(failures = emptyList(), remaining = emptyList()), outcome) } /** * **이 UseCase 의 존재 이유.** 지우려던 것이 이미 없어서 실패한 경우(404)는 사용자가 원한 - * 결과가 이뤄진 것이다. 그것까지 실패로 올리면 화면이 「목록은 비었는데 1개 선택」 이 된다. + * 결과가 이뤄진 것이다. 그것까지 «다시 고르라» 고 하면 화면이 「목록은 비었는데 1개 선택」 이 된다. + * + * 다만 **계측에서는 사라지면 안 된다.** 화면에 안 보이는 실패일수록 콘솔이 유일한 흔적이라, + * 대조로 걸러 내면 「왜 안 지워졌나」를 나중에 물을 곳이 없어진다 (#964·#1693 리뷰). + * 그래서 화면이 보는 [Outcome.Deleted.remaining] 만 비고, [Outcome.failures] 에는 남는다. */ @Test - fun `실패했어도 재조회에서 사라졌으면 실패로 치지 않는다`() = + fun `재조회에서 사라진 실패는 화면에서 빠지되 계측에는 남는다`() = runTest { + val gone = IOException("이미 없음") val useCase = DeleteMindRecordDraftsUseCase( - diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(IOException("이미 없음")) }), + diaryRepository = FakeDiaryRepository(onDelete = { Result.failure(gone) }), dailyQuestionRepository = FakeDailyQuestionRepository(), ) @@ -61,7 +66,9 @@ class DeleteMindRecordDraftsUseCaseTest { survivorsAfterDelete = { emptySet() }, ) - assertEquals(Outcome.Deleted(failures = emptyList()), outcome) + assertEquals(emptyList(), (outcome as Outcome.Deleted).remaining) + assertEquals(listOf(Target(Category.Diary, 1)), outcome.failures.map { it.target }) + assertEquals(gone, outcome.failures.single().cause) } /** 반대로 **아직 남아 있는** 실패는 올려야 한다 — 그래야 다시 선택해 줄 수 있다. */ @@ -81,6 +88,7 @@ class DeleteMindRecordDraftsUseCaseTest { assertEquals(listOf(target), outcome.failures.map { it.target }) assertEquals(boom, outcome.failures.single().cause) assertTrue(outcome is Outcome.Deleted) + assertEquals(listOf(target), (outcome as Outcome.Deleted).remaining.map { it.target }) } /** @@ -108,11 +116,13 @@ class DeleteMindRecordDraftsUseCaseTest { survivorsAfterDelete = { setOf(Target(Category.Diary, 3)) }, ) + // 화면이 보는 것은 remaining 이다 — 계측용 failures 에는 남는 것이 정상이다 (#1693 리뷰). assertEquals( "사라진 데일리질문 3이 남아 있는 일기 3 때문에 실패로 살아남았다", emptyList(), - outcome.failures.map { it.target }, + (outcome as Outcome.Deleted).remaining.map { it.target }, ) + assertEquals(listOf(Target(Category.DailyQuestion, 3)), outcome.failures.map { it.target }) } /** @@ -154,7 +164,7 @@ class DeleteMindRecordDraftsUseCaseTest { emptySet() }) - assertEquals(Outcome.Deleted(failures = emptyList()), outcome) + assertEquals(Outcome.Deleted(failures = emptyList(), remaining = emptyList()), outcome) assertEquals(emptyList(), diaryRepository.deletedIds) assertTrue("대상이 없는데 재조회가 나갔다", !refreshed) } @@ -192,6 +202,6 @@ class DeleteMindRecordDraftsUseCaseTest { advanceUntilIdle() assertTrue("두 삭제가 겹치지 않았다", outcome.isCompleted) - assertEquals(Outcome.Deleted(failures = emptyList()), outcome.await()) + assertEquals(Outcome.Deleted(failures = emptyList(), remaining = emptyList()), outcome.await()) } } diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt index e6cff00dc6..60da8cbc55 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/usecase/ObserveWeeklyReportUseCaseTest.kt @@ -98,9 +98,10 @@ class ObserveWeeklyReportUseCaseTest { useCase.observe(thisMonday, today).toList() + // 요청 횟수와 흐른 시간이 곧 상한이다 — 상수를 따로 단언하면 프로덕션 visibility 를 + // 테스트 때문에 넓히게 된다 (#1688·#1693 리뷰). assertEquals("첫 조회 1회 + 폴링 8회", 9, repository.requestedDates.size) - assertEquals(8, ObserveWeeklyReportUseCase.POLL_ATTEMPTS) - assertEquals(8_000L, ObserveWeeklyReportUseCase.POLL_INTERVAL_MILLIS) + assertEquals("8초 × 8회 = 64초", 64_000L, testScheduler.currentTime) } /** 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 3d3fe05b30..4ef0ef6cd3 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 @@ -61,7 +61,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -87,7 +87,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -118,7 +118,7 @@ class DailyQuestionWriteViewModelTest { uploadedUrl = "https://cdn/just-picked.jpg", uploadedKey = "mindrecords/1/just-picked.jpg", ), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -147,7 +147,7 @@ class DailyQuestionWriteViewModelTest { uploadedUrl = "https://cdn/picked.jpg", uploadedKey = "mindrecords/staging/13/picked.jpg", ), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -173,7 +173,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -192,7 +192,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -243,7 +243,7 @@ class DailyQuestionWriteViewModelTest { uploadedUrl = "https://cdn.example.net/mindrecords/staging/13/a.png", uploadedKey = "mindrecords/staging/13/a.png", ), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -272,7 +272,7 @@ class DailyQuestionWriteViewModelTest { SavedStateHandle(emptyMap()), repository, FakePhotoUploadRepository.strict(), - noopDraftLoader(), + noopLoadDraftsUseCase(), RecordingErrorReporter(), ) @@ -284,7 +284,7 @@ class DailyQuestionWriteViewModelTest { } /** 툴바 카운트는 이 테스트의 관심사가 아니다 — 0건으로 고정한다 (#769). */ -internal fun noopDraftLoader() = +internal fun noopLoadDraftsUseCase() = LoadMindRecordDraftsUseCase( diaryRepository = FakeDiaryRepository.strict().apply { diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index e45ac247cd..f3a4edfced 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -90,7 +90,8 @@ class DraftListDeleteTest { Result.failure(IllegalStateException("404")) } } - val viewModel = viewModel(repository) + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter) val target = (viewModel.uiState.value as DraftListUiState.Success).items viewModel.delete(target) @@ -98,6 +99,9 @@ class DraftListDeleteTest { val state = viewModel.uiState.value as DraftListUiState.Success assertEquals(DraftDeleteOutcome.AllDeleted, state.deleteOutcome) assertTrue(state.items.isEmpty()) + // 화면에서 빠지는 실패일수록 콘솔이 유일한 흔적이다. 재조회 대조로 계측까지 걸러 내면 + // 「왜 안 지워졌나」를 나중에 물을 곳이 없어진다 (#964·#1693 리뷰). + assertEquals(listOf("draft_delete"), reporter.stages) } @Test @@ -105,7 +109,8 @@ class DraftListDeleteTest { val repository = // 성공 삭제는 픽스처 기본 동작(저장소에서 제거 + success)이 그대로 맞다. FakeDailyQuestionRepository(initialAnswers = listOf(dailyQuestion(id = 1L))) - val viewModel = viewModel(repository) + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter) val target = (viewModel.uiState.value as DraftListUiState.Success).items viewModel.delete(target) @@ -113,6 +118,8 @@ class DraftListDeleteTest { val state = viewModel.uiState.value as DraftListUiState.Success assertEquals(DraftDeleteOutcome.AllDeleted, state.deleteOutcome) assertTrue(state.items.isEmpty()) + // 반대쪽 경계 — 성공에는 계측이 없어야 한다. 「전부 올린다」 로 되돌리면 여기가 먼저 깨진다. + assertEquals(emptyList(), reporter.stages) } private fun viewModel( diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt index 138b2098b8..1bade9a9ea 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportRecoveryTest.kt @@ -177,8 +177,41 @@ class WeeklyReportRecoveryTest { emotionAnalysis = EmotionAnalysis(total = 0, succeeded = 0, pending = 0, failed = 0), ) - private fun TestScope.start(repository: WeeklyReportRepository): WeeklyReportViewModel { - val viewModel = WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository()), changeTracker) + @Test + fun `재진입 갱신은 그 사이 바뀐 프로필 이름을 반영한다`() = + runTest(dispatcher) { + // 종전에는 이미 Loaded 이고 같은 주면 `copy(report =)` 로만 갱신해, 방금 받아 온 + // 이름을 버렸다. 설정에서 이름을 바꾸고 돌아와도 주를 옮기기 전까지 옛 이름이 + // 남는다 — 폴링 방출은 첫 조회의 이름을 그대로 싣고 오므로 갈라 둘 이유가 없다 (#1693 리뷰). + var name = "옛이름" + val userRepository = + FakeUserRepository.strict().apply { + onReceiverListFlow = { flowOf(emptyList()) } + onGetMyProfile = { User(name = name, email = "a@b.c", phone = null, profileImageUrl = null) } + } + val repository = ScriptedRepository(mapOf(thisMonday to Result.success(report()))) + val viewModel = start(repository, userRepository) + + assertEquals("옛이름", (viewModel.uiState.value as WeeklyReportUiState.Success).userName) + + // 화면에 들어온 첫 ON_RESUME 은 init 조회가 덮으므로 건너뛴다 (#736 리뷰). + viewModel.refreshOnReturn() + advanceUntilIdle() + + // 설정에서 이름을 바꾸고 돌아왔다. + name = "새이름" + changeTracker.notifyChanged() + viewModel.refreshOnReturn() + advanceUntilIdle() + + assertEquals("새이름", (viewModel.uiState.value as WeeklyReportUiState.Success).userName) + } + + private fun TestScope.start( + repository: WeeklyReportRepository, + userRepository: FakeUserRepository = userRepository(), + ): WeeklyReportViewModel { + val viewModel = WeeklyReportViewModel(ObserveWeeklyReportUseCase(repository, userRepository), changeTracker) backgroundScope.launch(dispatcher) { viewModel.uiState.collect { } } advanceUntilIdle() return viewModel From 74284866403aa4656333b626c086d45f9219c284 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 4 Sep 2026 10:19:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix(mindrecord):=20=EA=B3=84=EC=B8=A1?= =?UTF-8?q?=EC=9D=84=20=EC=9E=AC=EC=A1=B0=ED=9A=8C=20=EC=95=9E=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B8=B0=EA=B3=A0=20=EB=A6=AC=ED=8C=A9?= =?UTF-8?q?=ED=84=B0=20=EC=9E=94=EC=9E=AC=EB=A5=BC=20=EA=B1=B7=EB=8A=94?= =?UTF-8?q?=EB=8B=A4=20(#1875)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 삭제 실패 계측이 반환값 기반이라 재조회 도중 스코프가 취소되면 흔적이 통째로 사라졌다. 삭제는 이미 서버에 반영된 뒤다. UseCase 에 onFailures 훅을 두어 재조회 **전에** 한 번 넘긴다 — 보고 주체는 여전히 ViewModel 이고 UseCase 는 계측을 알지 않는다. - 그 창을 잠그는 테스트를 붙인다. 첫 조회는 통과시키고 삭제 뒤 재조회에서 CancellationException 을 던져 「화면을 벗어난」 자리를 재현한다. - 경계를 옮기며 남은 미사용 import 13건을 걷는다 — WeeklyReportViewModel 8건, DraftListViewModel 5건. develop 에서는 전부 쓰이던 것들이고 ktlint 는 잡지 않는다. 대표 이슈를 #1875 로 분리했다 — #1693 은 3단계까지 담고 있어 이 PR 로 닫히지 않아 Refs 로만 걸려 있었고, 링크 게이트가 Closes 를 요구한다. 역검증: onFailures 호출을 지우면 「재조회 도중 취소돼도 삭제 실패는 기록된다」와 「사라졌으면 완료로 본다」가 함께 FAILED. 255건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../usecase/DeleteMindRecordDraftsUseCase.kt | 8 +++++ .../viewmodel/DraftListViewModel.kt | 29 ++++++++-------- .../viewmodel/WeeklyReportViewModel.kt | 8 ----- .../viewmodel/DraftListDeleteTest.kt | 33 +++++++++++++++++++ 4 files changed, 57 insertions(+), 21 deletions(-) diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt index 1355259cef..de867930af 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/usecase/DeleteMindRecordDraftsUseCase.kt @@ -40,10 +40,15 @@ class DeleteMindRecordDraftsUseCase * @param targets 지울 대상. 비어 있으면 아무 것도 하지 않고 [Outcome.Deleted] 를 돌린다. * @param survivorsAfterDelete 삭제 뒤 다시 조회한 목록의 키. 재조회 자체가 실패했으면 * `null` — 그때는 무엇이 남았는지 알 수 없어 대조할 수 없으므로 [Outcome.Unknown] 이다. + * @param onFailures 실패 전건을 **재조회 전에** 한 번 넘긴다. 계측이 여기 걸린다. + * 반환값으로만 넘기면 재조회 도중 스코프가 취소될 때(사용자가 화면을 벗어날 때) + * 삭제는 이미 서버에 반영됐는데 실패는 한 건도 안 남는 창이 열린다 (#1693 리뷰). + * 보고 주체는 여전히 호출부다 — UseCase 는 계측을 알지 않는다. */ suspend fun delete( targets: List, survivorsAfterDelete: suspend () -> Set?, + onFailures: (List) -> Unit = {}, ): Outcome { if (targets.isEmpty()) return Outcome.Deleted(failures = emptyList(), remaining = emptyList()) @@ -57,6 +62,9 @@ class DeleteMindRecordDraftsUseCase result.exceptionOrNull()?.let { Failure(target = target, cause = it) } } + // 재조회 **전에** 넘긴다 — 그 사이 취소되면 실패가 통째로 사라진다. + onFailures(failures) + val survivors = survivorsAfterDelete() ?: return Outcome.Unknown(failures = failures) return Outcome.Deleted( failures = failures, diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt index df707e7eaa..2569007c03 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListViewModel.kt @@ -4,8 +4,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.ui.UiText -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.mapper.toUi import com.afternote.feature.mindrecord.presentation.reporting.MindRecordFailureStage @@ -14,9 +12,6 @@ import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDra import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -83,15 +78,23 @@ class DraftListViewModel refreshed = collectDrafts() refreshed?.mapNotNullTo(mutableSetOf()) { it.toDeleteTarget() } }, + // 되돌릴 수 없는 동작이라 실패는 콘솔에도 남겨야 한다 — 화면만 알리고 + // 끝내면 나중에 「왜 안 지워졌나」를 물을 곳이 없다 (#964). 화면에 안 + // 보이는 실패(이미 사라진 항목의 404)까지 기록한다 — 그쪽일수록 계측이 + // 유일한 흔적이다. + // + // **재조회 전에** 부르는 훅이다. 반환값으로 받아 여기서 기록하면 재조회 + // 도중 스코프가 취소될 때 삭제는 반영됐는데 흔적이 안 남는다 (#1693 리뷰). + onFailures = { failures -> + failures.forEach { failure -> + errorReporter.recordMindRecordFailure( + MindRecordFailureStage.DRAFT_DELETE, + failure.cause, + ) + } + }, ) - // 되돌릴 수 없는 동작이라 실패는 콘솔에도 남겨야 한다 — 화면만 알리고 끝내면 - // 나중에 「왜 안 지워졌나」를 물을 곳이 없다 (#964). 화면에 안 보이는 실패 - // (이미 사라진 항목의 404)까지 기록한다 — 그쪽일수록 계측이 유일한 흔적이다. - outcome.failures.forEach { failure -> - errorReporter.recordMindRecordFailure(MindRecordFailureStage.DRAFT_DELETE, failure.cause) - } - // 재조회 결과가 없으면 **현 목록을 유지한다**. UseCase 는 targets 가 비면 // survivorsAfterDelete 를 부르지 않고 Deleted 를 돌리므로 `refreshed` 가 null 인 // 채 여기 올 수 있다 — orEmpty() 로 두면 목록이 빈 리스트로 갈린다. 지금은 위 @@ -104,7 +107,7 @@ class DraftListViewModel items = loaded.orEmpty(), // 화면은 **남은 것**만 본다 — 이미 사라진 항목의 404 까지 «실패» 로 // 보이면 사용자가 다시 지울 수 없는 것을 다시 고르게 된다. - // 계측은 위에서 전건을 이미 올렸다 (#1693). + // 계측은 onFailures 훅이 재조회 전에 전건을 이미 올렸다 (#1693). deleteOutcome = if (outcome.remaining.isEmpty()) { DraftDeleteOutcome.AllDeleted diff --git a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt index f14c45c9e1..c3ff245b0d 100644 --- a/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt +++ b/feature/mindrecord/presentation/src/main/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/WeeklyReportViewModel.kt @@ -1,10 +1,7 @@ package com.afternote.feature.mindrecord.presentation.viewmodel -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository import com.afternote.core.ui.UiText import com.afternote.feature.mindrecord.domain.model.EmotionAnalysisStatus import com.afternote.feature.mindrecord.domain.model.TodayMood @@ -12,7 +9,6 @@ import com.afternote.feature.mindrecord.domain.model.WeeklyReport import com.afternote.feature.mindrecord.domain.model.WeeklyReportDailyQuestion import com.afternote.feature.mindrecord.domain.model.WeeklyReportDay import com.afternote.feature.mindrecord.domain.model.WeeklyReportEmotion -import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository import com.afternote.feature.mindrecord.domain.sync.MindRecordChangeTracker import com.afternote.feature.mindrecord.presentation.R import com.afternote.feature.mindrecord.presentation.model.DailyQuestion @@ -25,10 +21,6 @@ import com.afternote.feature.mindrecord.presentation.usecase.ObserveWeeklyReport import com.afternote.feature.mindrecord.presentation.usecase.analysisStatus import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.currentCoroutineContext -import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted diff --git a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt index f3a4edfced..19cdd3711e 100644 --- a/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt +++ b/feature/mindrecord/presentation/src/test/kotlin/com/afternote/feature/mindrecord/presentation/viewmodel/DraftListDeleteTest.kt @@ -11,6 +11,7 @@ import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import com.afternote.feature.mindrecord.presentation.usecase.DeleteMindRecordDraftsUseCase import com.afternote.feature.mindrecord.presentation.usecase.LoadMindRecordDraftsUseCase +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -104,6 +105,38 @@ class DraftListDeleteTest { assertEquals(listOf("draft_delete"), reporter.stages) } + /** + * 재조회 도중 스코프가 취소되는 창. 삭제는 이미 서버에 반영됐으므로 **그 실패는 남아야 + * 한다** — 반환값으로 받아 기록하면 이 경로에서 흔적이 통째로 사라진다 (#1693 리뷰). + */ + @Test + fun `재조회 도중 취소돼도 삭제 실패는 기록된다`() { + var listCalls = 0 + val repository = + FakeDailyQuestionRepository( + initialAnswers = listOf(dailyQuestion(id = 1L)), + onDelete = { Result.failure(IllegalStateException("서버 거절")) }, + ).apply { + // 첫 조회(초기 로드)는 통과시키고, 삭제 뒤 재조회에서 취소를 흉내 낸다 — + // 사용자가 화면을 벗어난 자리다. + onGetList = { _, _ -> + listCalls += 1 + if (listCalls == 1) { + Result.success(listOf(dailyQuestion(id = 1L))) + } else { + throw CancellationException("화면 이탈") + } + } + } + val reporter = RecordingErrorReporter() + val viewModel = viewModel(repository, reporter) + val target = (viewModel.uiState.value as DraftListUiState.Success).items + + runCatching { viewModel.delete(target) } + + assertEquals("재조회 취소로 삭제 실패의 흔적이 사라졌다", listOf("draft_delete"), reporter.stages) + } + @Test fun `모두 성공하면 완료로 알린다`() { val repository =