Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,16 @@ 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.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
import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel
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
Expand Down Expand Up @@ -459,7 +461,7 @@ class TimeLetterMindRecordCompletionAndroidTest {
savedStateHandle = SavedStateHandle(emptyMap()),
repository = repository,
photoUploadRepository = FakePhotoUploadRepository.strict(),
draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository),
draftLoader = LoadMindRecordDraftsUseCase(FakeDiaryRepository(), repository),
errorReporter = FakeErrorReporter(),
)
}
Expand Down Expand Up @@ -515,9 +517,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<Long, String>? = null
Expand Down Expand Up @@ -546,7 +547,7 @@ class TimeLetterMindRecordCompletionAndroidTest {
photoUploadRepository = FakePhotoUploadRepository.strict(),
userRepository = appTestUserRepository(),
draftLoader =
MindRecordDraftLoader(repository, draftDailyQuestionRepository),
LoadMindRecordDraftsUseCase(repository, draftDailyQuestionRepository),
errorReporter = FakeErrorReporter(),
)
},
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
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] 이다.
* @param onFailures 실패 전건을 **재조회 전에** 한 번 넘긴다. 계측이 여기 걸린다.
* 반환값으로만 넘기면 재조회 도중 스코프가 취소될 때(사용자가 화면을 벗어날 때)
* 삭제는 이미 서버에 반영됐는데 실패는 한 건도 안 남는 창이 열린다 (#1693 리뷰).
* 보고 주체는 여전히 호출부다 — UseCase 는 계측을 알지 않는다.
*/
suspend fun delete(
targets: List<Target>,
survivorsAfterDelete: suspend () -> Set<Target>?,
onFailures: (List<Failure>) -> Unit = {},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[normal] 이 훅에 기본값을 주면, 훅이 막으려던 창이 새 호출부에서 조용히 다시 열립니다.

onFailures 는 「반환값으로 받으면 재조회 취소 때 실패가 사라진다」를 막으려고 만든 자리입니다. 그런데 = {} 가 있으면 앞으로 이 UseCase 를 쓰는 코드가 인자를 빼먹어도 컴파일이 통과하고, 계측 0 인 채로 조용히 돌아갑니다. 잃어버리는 게 바로 이 PR 이 되찾은 그 실패들입니다.

지금은 호출부가 DraftListViewModel 하나뿐이라 실제 결함은 없습니다. 다만 이 기본값의 유일한 이득이 「테스트에서 안 넘겨도 됨」인데, 테스트야말로 계측을 단언하는 쪽이라 대부분 넘기게 됩니다.

기본값을 빼고 필수 인자로 두면 새 호출부가 계측을 의식적으로 선택하게 됩니다. 정말 계측이 필요 없는 호출부가 생기면 그때 {} 를 명시적으로 넘기게 하는 편이, 빼먹은 것과 안 하기로 한 것이 코드에서 갈립니다.

): Outcome {
if (targets.isEmpty()) return Outcome.Deleted(failures = emptyList(), remaining = 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) }
}

// 재조회 **전에** 넘긴다 — 그 사이 취소되면 실패가 통째로 사라진다.
onFailures(failures)

val survivors = survivorsAfterDelete() ?: return Outcome.Unknown(failures = failures)
return Outcome.Deleted(
failures = failures,
remaining = failures.filter { it.target in survivors },
)
}

private suspend fun deleteOne(target: Target): Result<Unit> =
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 {
/**
* 이번 삭제에서 난 **실패 전건**. 재조회 대조와 무관하게 그대로 싣는다.
*
* 계측이 보는 것은 이쪽이다. 「이미 사라진 항목의 404」처럼 화면에 안 보이는 실패일수록
* 계측이 유일한 흔적이라, 대조로 걸러 내면 콘솔에서도 사라진다 (#964·#1693).
*/
val failures: List<Failure>

/**
* 삭제와 재조회가 모두 끝났다.
*
* 화면이 쓰는 것은 [remaining] 이다 — 비어 있으면 사용자가 원한 결과가 전부 이뤄졌다.
* [failures] 와 갈리는 이유는 위 KDoc 에 있다.
*/
data class Deleted(
override val failures: List<Failure>,
/** [failures] 중 재조회 목록에 **아직 남아 있는** 것. 다시 선택해 줄 대상이다. */
val remaining: List<Failure>,
) : Outcome

/**
* 재조회가 실패해 무엇이 남았는지 모른다. 목록을 그릴 수 없으므로 화면은 오류로 간다.
* 그래도 [failures] 는 계측을 위해 그대로 싣는다.
*/
data class Unknown(
override val failures: List<Failure>,
) : Outcome
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -31,14 +31,19 @@ class MindRecordDraftLoader
*
* 한쪽이라도 조회에 실패하면 실패로 올린다 — 빈 목록으로 흡수하면 "실패" 와 "0건" 이
* 같은 화면이 되어 복구 수단이 사라진다.
*
* @param currentMonth 일기를 조회할 달. 종전에는 [YearMonth.now] 를 이 안에서 직접
* 불렀는데, 그러면 **어느 달을 요청했는가를 벽시계 없이 단언할 수 없다** — 기대값을
* 만들려고 테스트가 같은 `now()` 를 다시 부르면 구현과 기대가 같은 실수를 공유하고,
* 달이 바뀌는 자정에는 판정 자체가 흔들린다. 기본값이 있어 호출부는 종전 그대로다 (#1693).
*/
suspend fun load(): Result<Drafts> =
suspend fun load(currentMonth: YearMonth = YearMonth.now()): Result<Drafts> =
runCatchingCancellable {
coroutineScope {
val diariesDeferred =
async {
diaryRepository
.getList(yearMonth = YearMonth.now().toString(), draftOnly = true)
.getList(yearMonth = currentMonth.toString(), draftOnly = true)
.getOrThrow()
.diaries
}
Expand All @@ -54,7 +59,7 @@ class MindRecordDraftLoader
}

/** 툴바 카운트용 — 개수만 필요할 때. */
suspend fun count(): Result<Int> = load().map { it.total }
suspend fun count(currentMonth: YearMonth = YearMonth.now()): Result<Int> = load(currentMonth).map { it.total }

data class Drafts(
val diaries: List<Diary>,
Expand Down
Loading
Loading