diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa19a5f5ab..e59a83e18d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -238,6 +238,7 @@ dependencies { androidTestImplementation(projects.core.data) androidTestImplementation(projects.feature.afternote.domain) androidTestImplementation(projects.feature.mindrecord.domain) + androidTestImplementation(testFixtures(projects.feature.mindrecord.domain)) androidTestImplementation(projects.feature.receiver.domain) androidTestImplementation(projects.feature.timeletter.domain) kspAndroidTest(libs.hilt.compiler) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt index fa987029b6..6c74007cbd 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/AppAndReceiverCompletionAndroidTest.kt @@ -47,7 +47,7 @@ import com.afternote.feature.afternote.domain.AfternoteType import com.afternote.feature.afternote.domain.model.LeaveMessageBlock import com.afternote.feature.home.presentation.HomeTabActions import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords -import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository import com.afternote.feature.mindrecord.presentation.model.MindRecordCategory import com.afternote.feature.receiver.domain.error.ReceiverFailure import com.afternote.feature.receiver.domain.model.AfterNoteListItem @@ -234,7 +234,10 @@ class ReceiverRuntimeCompletionAndroidTest { @Test fun receiverHome_allFailureThenRetryPartialSuccess_keepsAvailableSectionsAndReportsBothStages() { val repository = CompletionReceiverRepository() - val mindRecordRepository = CompletionMindRecordReceiverRepository() + // 완료 시점을 테스트가 쥐어야 세 조회의 경합 순서를 만들 수 있다. + val mindRecordHomeResults = ArrayDeque>>() + val mindRecordRepository = + FakeMindRecordReceiverRepository(onGetAll = { mindRecordHomeResults.removeFirst().await() }) val timeLetterRepository = CompletionReceiverTimeLetterRepository() fun homeCallCounts(): List = @@ -245,8 +248,8 @@ class ReceiverRuntimeCompletionAndroidTest { repository.senderMessageCalls, ) - val allFailureAttempt = enqueueHomeAttempt(repository, mindRecordRepository, timeLetterRepository) - val partialAttempt = enqueueHomeAttempt(repository, mindRecordRepository, timeLetterRepository) + val allFailureAttempt = enqueueHomeAttempt(repository, mindRecordHomeResults, timeLetterRepository) + val partialAttempt = enqueueHomeAttempt(repository, mindRecordHomeResults, timeLetterRepository) val reporter = FakeErrorReporter() val viewModel = ReceiverHomeViewModel( @@ -744,7 +747,7 @@ private data class PendingHomeAttempt( /** 홈 한 번의 로드가 물리는 세 리포지토리 대기열에 결과 게이트를 한 벌씩 건다. */ private fun enqueueHomeAttempt( receiverRepository: CompletionReceiverRepository, - mindRecordRepository: CompletionMindRecordReceiverRepository, + mindRecordHomeResults: ArrayDeque>>, timeLetterRepository: CompletionReceiverTimeLetterRepository, ): PendingHomeAttempt { val attempt = @@ -755,7 +758,7 @@ private fun enqueueHomeAttempt( senderMessage = CompletableDeferred(), ) receiverRepository.afterNoteHomeResults.addLast(attempt.afterNotes) - mindRecordRepository.homeResults.addLast(attempt.mindRecords) + mindRecordHomeResults.addLast(attempt.mindRecords) timeLetterRepository.homeResults.addLast(attempt.timeLetters) receiverRepository.senderMessageHomeResults.addLast(attempt.senderMessage) return attempt @@ -807,17 +810,6 @@ private class CompletionReceiverRepository : ReceiverRepository { } } -private class CompletionMindRecordReceiverRepository : MindRecordReceiverRepository { - val homeResults = ArrayDeque>>() - var getAllCalls = 0 - private set - - override suspend fun getAll(): Result { - getAllCalls += 1 - return homeResults.removeFirst().await() - } -} - private class CompletionReceiverTimeLetterRepository : ReceiverTimeLetterRepository { val homeResults = ArrayDeque>>() var listCalls = 0 diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt index 71f6d13186..456c85b010 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordFlowAndroidTest.kt @@ -14,12 +14,12 @@ import androidx.compose.ui.test.waitUntilAtLeastOneExists import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.afternote.afternote_fe.test.FailureArtifactRule -import com.afternote.afternote_fe.test.FakeDailyQuestionRepository -import com.afternote.afternote_fe.test.FakeDiaryRepository import com.afternote.afternote_fe.test.FakeUserRepository import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.core.ui.theme.AfternoteTheme import com.afternote.feature.mindrecord.domain.model.TodayMood +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.screen.sender.DiaryWriteScreen import com.afternote.feature.mindrecord.presentation.viewmodel.DailyQuestionWriteViewModel import com.afternote.feature.mindrecord.presentation.viewmodel.DiaryWriteViewModel @@ -46,8 +46,15 @@ class MindRecordFlowAndroidTest { @Test fun diaryFailureThenRetry_preservesInputAndSendsExactPayload() { val repository = FakeDiaryRepository() - repository.createResults.addLast(Result.failure(IllegalStateException("temporary"))) - repository.createResults.addLast(Result.success(Unit)) + // 첫 저장은 실패, 재시도는 성공 — 입력이 남는지와 정확한 payload 를 본다. + val createResults = + ArrayDeque( + listOf( + Result.failure(IllegalStateException("temporary")), + Result.success(Unit), + ), + ) + repository.onCreate = { createResults.removeFirst() } val viewModel = diaryViewModel(repository) var successCalls = 0 @@ -105,8 +112,15 @@ class MindRecordFlowAndroidTest { @Test fun dailyQuestion_failureThenRetry_keepsAnswerAndAvoidsDuplicateSuccess() { val repository = FakeDailyQuestionRepository() - repository.createResults.addLast(Result.failure(IllegalStateException("offline"))) - repository.createResults.addLast(Result.success(1L)) + // 첫 저장은 실패, 재시도는 성공 — 실패 후 답변이 남는지와 중복 성공이 없는지를 본다. + val createResults = + ArrayDeque( + listOf( + Result.failure(IllegalStateException("offline")), + Result.success(FakeDailyQuestionRepository.FIRST_CREATED_ID), + ), + ) + repository.onCreate = { createResults.removeFirst() } val viewModel = DailyQuestionWriteViewModel( savedStateHandle = SavedStateHandle(emptyMap()), diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt index 489f03dc25..523c41690d 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/MindRecordLifecycleAndroidTest.kt @@ -27,15 +27,15 @@ import com.afternote.feature.mindrecord.domain.model.EmotionAnalysis import com.afternote.feature.mindrecord.domain.model.MindRecordSummary import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords -import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.model.TodayMood 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.repository.DailyQuestionRepository -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository -import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository -import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.unexpectedCall import com.afternote.feature.mindrecord.presentation.model.DayBackground import com.afternote.feature.mindrecord.presentation.model.DayContent import com.afternote.feature.mindrecord.presentation.screen.receiver.ReceiverMindRecordScreen @@ -82,13 +82,14 @@ class MindRecordLifecycleAndroidTest { val currentPublished = diary(101L, "이번 달 공개 일기", currentMonth.atDay(3), isDraft = false) val currentDraft = diary(102L, "숨겨야 할 임시 일기", currentMonth.atDay(4), isDraft = true) val previousPublished = diary(103L, "지난달 삭제할 일기", previousMonth.atDay(5), isDraft = false) - val repository = - PrivateDiaryRepository().apply { - lists[PrivateDiaryQuery(currentMonth.toString(), null)] = - DiaryList(listOf(currentPublished, currentDraft), 2, TodayMood.HAPPY) - lists[PrivateDiaryQuery(previousMonth.toString(), null)] = - DiaryList(listOf(previousPublished), 1, TodayMood.SOSO) - } + val diaryLists = + mutableMapOf( + FakeDiaryRepository.ListQuery(currentMonth.toString(), null) to + DiaryList(listOf(currentPublished, currentDraft), 2, TodayMood.HAPPY), + FakeDiaryRepository.ListQuery(previousMonth.toString(), null) to + DiaryList(listOf(previousPublished), 1, TodayMood.SOSO), + ) + val repository = scriptedDiaryRepository(diaryLists) val viewModel = DiaryListViewModel(repository) composeRule.setContent { @@ -113,14 +114,14 @@ class MindRecordLifecycleAndroidTest { composeRule.onNodeWithContentDescription("더보기 메뉴").performClick() composeRule.onNodeWithText("삭제하기").performClick() composeRule.onNodeWithText("아직 등록된 일기가 없어요.").assertIsDisplayed() - assertEquals(listOf(103L), repository.deleteCalls) + assertEquals(listOf(103L), repository.deletedIds) assertEquals( listOf( - PrivateDiaryQuery(currentMonth.toString(), null), - PrivateDiaryQuery(previousMonth.toString(), null), - PrivateDiaryQuery(previousMonth.toString(), null), + FakeDiaryRepository.ListQuery(currentMonth.toString(), null), + FakeDiaryRepository.ListQuery(previousMonth.toString(), null), + FakeDiaryRepository.ListQuery(previousMonth.toString(), null), ), - repository.listCalls, + repository.listQueries, ) val state = viewModel.uiState.value as DiaryListUiState.Success assertEquals(previousMonth, state.yearMonth) @@ -130,18 +131,23 @@ class MindRecordLifecycleAndroidTest { @Test fun combinedDraftList_routesSelectedAndAllDeletesToExactRepositories() { val currentMonth = YearMonth.now() - val diaryRepository = - PrivateDiaryRepository().apply { - lists[PrivateDiaryQuery(currentMonth.toString(), true)] = + val diaryLists = + mutableMapOf( + FakeDiaryRepository.ListQuery(currentMonth.toString(), true) to DiaryList( diaries = listOf(diary(201L, "일기 임시저장", currentMonth.atDay(8), isDraft = true)), monthDiaryCount = 1, weeklyDominantMood = null, - ) - } + ), + ) + val diaryRepository = scriptedDiaryRepository(diaryLists) val dailyQuestionRepository = - PrivateDailyQuestionRepository( - questions = + FakeDailyQuestionRepository( + // 이 시나리오는 임시저장 목록의 조회·삭제만 태운다. + onGetToday = { unexpectedCall("DailyQuestionRepository.getToday") }, + onCreate = { unexpectedCall("DailyQuestionRepository.create") }, + onUpdate = { _, _ -> unexpectedCall("DailyQuestionRepository.update") }, + initialAnswers = listOf( DailyQuestion( dailyQuestionId = 202L, @@ -179,8 +185,8 @@ class MindRecordLifecycleAndroidTest { .assertIsDisplayed() composeRule.onNodeWithText("질문 임시저장").assertDoesNotExist() composeRule.onNodeWithText("일기 임시저장").assertIsDisplayed() - assertEquals(listOf(202L), dailyQuestionRepository.deleteCalls) - assertTrue(diaryRepository.deleteCalls.isEmpty()) + assertEquals(listOf(202L), dailyQuestionRepository.deletedIds) + assertTrue(diaryRepository.deletedIds.isEmpty()) composeRule.waitUntil(timeoutMillis = TIMEOUT) { (viewModel.uiState.value as? DraftListUiState.Success)?.deleteOutcome == null @@ -188,16 +194,16 @@ class MindRecordLifecycleAndroidTest { composeRule.onNodeWithText("선택").performClick() composeRule.onNodeWithText("전체 삭제").performClick() composeRule.onNodeWithText("임시 저장된 항목이 없습니다.").assertIsDisplayed() - assertEquals(listOf(201L), diaryRepository.deleteCalls) - assertEquals(listOf(202L), dailyQuestionRepository.deleteCalls) - assertEquals(3, diaryRepository.listCalls.size) - assertEquals(3, dailyQuestionRepository.listCalls.size) + assertEquals(listOf(201L), diaryRepository.deletedIds) + assertEquals(listOf(202L), dailyQuestionRepository.deletedIds) + assertEquals(3, diaryRepository.listQueries.size) + assertEquals(3, dailyQuestionRepository.listQueries.size) assertTrue((viewModel.uiState.value as DraftListUiState.Success).items.isEmpty()) } @Test fun weeklyReport_errorRetry_mapsSparsePartialWeekWithoutIndexDrift() { - val repository = PrivateWeeklyReportRepository() + val repository = FakeWeeklyReportRepository() repository.results.addLast(Result.failure(IllegalStateException("weekly offline"))) val userRepository = privateProfileRepository("테스트 사용자") val viewModel = WeeklyReportViewModel(repository, userRepository) @@ -264,23 +270,25 @@ class MindRecordLifecycleAndroidTest { @Test fun receiverQuestionAndDiary_filterThenReset_reusesSingleRepositorySnapshot() { val repository = - PrivateMindRecordReceiverRepository( - records = - ReceiverMindRecords( - dailyQuestions = - listOf( - record(401L, MindRecordType.DAILY_QUESTION, "범위 밖 질문", "2026-07-01"), - record(402L, MindRecordType.DAILY_QUESTION, "질문 범위 시작", "2026-08-12"), - record(403L, MindRecordType.DAILY_QUESTION, "질문 범위 최신", "2026-08-20"), - record(404L, MindRecordType.DAILY_QUESTION, "숨길 질문 draft", "2026-08-21", true), - ), - diaries = - listOf( - record(411L, MindRecordType.DIARY, "범위 밖 일기", "2026-07-02"), - record(412L, MindRecordType.DIARY, "일기 범위 시작", "2026-08-13"), - record(413L, MindRecordType.DIARY, "일기 범위 최신", "2026-08-19"), - record(414L, MindRecordType.DIARY, "숨길 일기 draft", "2026-08-22", true), - ), + FakeMindRecordReceiverRepository( + result = + Result.success( + ReceiverMindRecords( + dailyQuestions = + listOf( + record(401L, MindRecordType.DAILY_QUESTION, "범위 밖 질문", "2026-07-01"), + record(402L, MindRecordType.DAILY_QUESTION, "질문 범위 시작", "2026-08-12"), + record(403L, MindRecordType.DAILY_QUESTION, "질문 범위 최신", "2026-08-20"), + record(404L, MindRecordType.DAILY_QUESTION, "숨길 질문 draft", "2026-08-21", true), + ), + diaries = + listOf( + record(411L, MindRecordType.DIARY, "범위 밖 일기", "2026-07-02"), + record(412L, MindRecordType.DIARY, "일기 범위 시작", "2026-08-13"), + record(413L, MindRecordType.DIARY, "일기 범위 최신", "2026-08-19"), + record(414L, MindRecordType.DIARY, "숨길 일기 draft", "2026-08-22", true), + ), + ), ), ) val viewModel = ReceiverMindRecordViewModel(repository, FakeErrorReporter()) @@ -334,99 +342,24 @@ class MindRecordLifecycleAndroidTest { } } -private data class PrivateDiaryQuery( - val yearMonth: String, - val draftOnly: Boolean?, -) - -private class PrivateDiaryRepository : DiaryRepository { - val lists = mutableMapOf() - val listCalls = mutableListOf() - val deleteCalls = mutableListOf() - - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result { - val query = PrivateDiaryQuery(yearMonth, draftOnly) - listCalls += query - return Result.success(lists[query] ?: DiaryList(emptyList(), 0, null)) - } - - override suspend fun create(payload: DiaryCreatePayload): Result = error("Unexpected diary create") - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result = error("Unexpected diary update") - - override suspend fun delete(id: Long): Result { - deleteCalls += id - lists.replaceAll { _, list -> - val remaining = list.diaries.filterNot { it.diaryId == id } - list.copy(diaries = remaining, monthDiaryCount = remaining.size) - } - return Result.success(Unit) - } -} - -private data class PrivateDailyQuestionQuery( - val date: String?, - val draftOnly: Boolean?, -) - -private class PrivateDailyQuestionRepository( - questions: List, -) : DailyQuestionRepository { - private var questions = questions - val listCalls = mutableListOf() - val deleteCalls = mutableListOf() - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> { - listCalls += PrivateDailyQuestionQuery(date, draftOnly) - return Result.success(questions) - } - - override suspend fun getToday(): Result = error("Unexpected today question load") - - override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("Unexpected question create") - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = error("Unexpected question update") - - override suspend fun delete(id: Long): Result { - deleteCalls += id - questions = questions.filterNot { it.dailyQuestionId == id } - return Result.success(Unit) - } -} - -private class PrivateWeeklyReportRepository : WeeklyReportRepository { - val results = ArrayDeque>() - val requestedDates = mutableListOf() - - override suspend fun getWeeklyReport(date: String): Result { - requestedDates += date - return requireNotNull(results.removeFirstOrNull()) - } -} - -private class PrivateMindRecordReceiverRepository( - private val records: ReceiverMindRecords, -) : MindRecordReceiverRepository { - var getAllCalls = 0 - private set - - override suspend fun getAll(): Result { - getAllCalls += 1 - return Result.success(records) - } -} +/** 달·임시저장 여부마다 다른 목록을 돌려주는 조회. 삭제는 그 목록들에서 함께 지운다. */ +private fun scriptedDiaryRepository(lists: MutableMap) = + FakeDiaryRepository( + onGetList = { yearMonth, draftOnly -> + Result.success( + lists[FakeDiaryRepository.ListQuery(yearMonth, draftOnly)] ?: DiaryList(emptyList(), 0, null), + ) + }, + onCreate = { unexpectedCall("DiaryRepository.create") }, + onUpdate = { _, _ -> unexpectedCall("DiaryRepository.update") }, + onDelete = { id -> + lists.replaceAll { _, list -> + val remaining = list.diaries.filterNot { it.diaryId == id } + list.copy(diaries = remaining, monthDiaryCount = remaining.size) + } + Result.success(Unit) + }, + ) @Suppress("UNCHECKED_CAST") private fun privateProfileRepository(name: String): UserRepository = 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 f842babe97..4de0576bc2 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/TimeLetterMindRecordCompletionAndroidTest.kt @@ -49,9 +49,9 @@ 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.DailyQuestionRepository -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository -import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeWeeklyReportRepository import com.afternote.feature.mindrecord.presentation.screen.memoryspace.MemorySpaceScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionAnswerListScreen import com.afternote.feature.mindrecord.presentation.screen.sender.DailyQuestionWriteScreen @@ -333,7 +333,16 @@ class TimeLetterMindRecordCompletionAndroidTest { @Test fun mindRecordHome_dailyQuestionLoadingEmptyAndErrorRetrySuccess_areRendered() { val emptyGate = CompletableDeferred>>() - val emptyRepository = CompletionDailyQuestionRepository(nextListGate = emptyGate) + val emptyRepository = + FakeDailyQuestionRepository(today = completionToday()).apply { + // 첫 조회를 붙잡아 로딩 상태를 만든다. 그 뒤 조회는 저장소 기본 동작(0건). + var gate: CompletableDeferred>>? = emptyGate + onGetList = { _, _ -> + val pending = gate + gate = null + pending?.await() ?: Result.success(emptyList()) + } + } var activeViewModel by mutableStateOf(DailyQuestionListViewModel(emptyRepository)) composeRule.setContent { @@ -342,7 +351,7 @@ class TimeLetterMindRecordCompletionAndroidTest { } } - composeRule.waitUntil(timeoutMillis = TIMEOUT) { emptyRepository.listCalls == 1 } + composeRule.waitUntil(timeoutMillis = TIMEOUT) { emptyRepository.listQueries.size == 1 } assertEquals(DailyQuestionListUiState.Loading, activeViewModel.uiState.value) composeRule .onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)) @@ -354,9 +363,13 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("아직 등록된 답변이 없어요.").assertIsDisplayed() + val listResults = + ArrayDeque>>().apply { + addLast(Result.failure(IllegalStateException("home offline"))) + } val retryRepository = - CompletionDailyQuestionRepository().apply { - listResults.addLast(Result.failure(IllegalStateException("home offline"))) + FakeDailyQuestionRepository(today = completionToday()).apply { + onGetList = { _, _ -> listResults.removeFirst() } } val retryViewModel = DailyQuestionListViewModel(retryRepository) composeRule.runOnIdle { activeViewModel = retryViewModel } @@ -365,7 +378,7 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("home offline").assertIsDisplayed() - retryRepository.listResults.addLast( + listResults.addLast( Result.success( listOf( dailyQuestion(id = 81L, title = "재시도로 돌아온 답변"), @@ -379,13 +392,13 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("재시도로 돌아온 답변").assertIsDisplayed() composeRule.onNodeWithText("노출되면 안 되는 임시답변").assertDoesNotExist() - assertEquals(2, retryRepository.listCalls) - assertEquals(2, retryRepository.todayCalls) + assertEquals(2, retryRepository.listQueries.size) + assertEquals(2, retryRepository.getTodayCalls) } @Test fun dailyQuestionWrite_successRefreshesExistingListWithCreatedAnswer() { - val repository = CompletionDailyQuestionRepository() + val repository = FakeDailyQuestionRepository(today = completionToday()) val listViewModel = DailyQuestionListViewModel(repository) var writeViewModel by mutableStateOf(null) var submitSuccessCalls = 0 @@ -419,7 +432,7 @@ class TimeLetterMindRecordCompletionAndroidTest { savedStateHandle = SavedStateHandle(emptyMap()), repository = repository, photoUploadRepository = CompletionPhotoUploadRepository, - draftLoader = MindRecordDraftLoader(CompletionDiaryRepository(mutableListOf()), repository), + draftLoader = MindRecordDraftLoader(FakeDiaryRepository(), repository), ) } composeRule.waitUntil(timeoutMillis = TIMEOUT) { @@ -437,11 +450,11 @@ class TimeLetterMindRecordCompletionAndroidTest { } composeRule.onNodeWithText("오늘의 테스트 질문").assertIsDisplayed() composeRule.onNodeWithText("작성 후 목록에 반영될 답변").assertIsDisplayed() - assertEquals(1, repository.createCalls.size) - assertEquals(71L, repository.createCalls.single().questionId) - assertEquals(false, repository.createCalls.single().isDraft) - assertEquals(2, repository.listCalls) - assertEquals(3, repository.todayCalls) + assertEquals(1, repository.createdPayloads.size) + assertEquals(71L, repository.createdPayloads.single().questionId) + assertEquals(false, repository.createdPayloads.single().isDraft) + assertEquals(2, repository.listQueries.size) + assertEquals(3, repository.getTodayCalls) } @Test @@ -449,9 +462,9 @@ class TimeLetterMindRecordCompletionAndroidTest { val currentMonth = YearMonth.now() val draftDate = currentMonth.atDay(12) val repository = - CompletionDiaryRepository( - drafts = - mutableListOf( + FakeDiaryRepository( + initialDiaries = + listOf( Diary( diaryId = 612L, title = "이어 쓸 일기", @@ -464,7 +477,7 @@ class TimeLetterMindRecordCompletionAndroidTest { ), ), ) - val draftDailyQuestionRepository = CompletionDailyQuestionRepository() + val draftDailyQuestionRepository = FakeDailyQuestionRepository(today = completionToday()) val draftListViewModel = DraftListViewModel( loader = MindRecordDraftLoader(repository, draftDailyQuestionRepository), @@ -530,25 +543,27 @@ class TimeLetterMindRecordCompletionAndroidTest { composeRule.onNode(hasText("등록") and hasClickAction()).performClick() composeRule.waitUntil(timeoutMillis = TIMEOUT) { submitSuccessCalls == 1 } - val update = repository.updateCalls.single() + val update = repository.updatedPayloads.single() assertEquals(612L, update.first) assertEquals("완성한 일기", update.second.title) assertEquals("

완성한 본문

", update.second.content) assertEquals(false, update.second.isDraft) assertEquals(TodayMood.SOSO, update.second.todayMood) // date·imageUrl 은 수정 요청 계약에 없어 페이로드에서 걷었다 (#955). - assertTrue(repository.createCalls.isEmpty()) - assertEquals(2, repository.listCalls.size) - assertTrue(repository.listCalls.all { it == currentMonth.toString() to true }) + assertTrue(repository.createdPayloads.isEmpty()) + assertEquals(2, repository.listQueries.size) + assertTrue( + repository.listQueries.all { it == FakeDiaryRepository.ListQuery(currentMonth.toString(), true) }, + ) } @Test fun memorySpace_supportedSuccess_opensAndClosesDetailThenNavigatesBack() { val memoryDate = LocalDate.now() val diaryRepository = - CompletionDiaryRepository( - drafts = - mutableListOf( + FakeDiaryRepository( + initialDiaries = + listOf( Diary( diaryId = 501L, title = "추억이 된 하루", @@ -561,7 +576,7 @@ class TimeLetterMindRecordCompletionAndroidTest { ), ), ) - val viewModel = MemorySpaceViewModel(diaryRepository, CompletionDailyQuestionRepository()) + val viewModel = MemorySpaceViewModel(diaryRepository, FakeDailyQuestionRepository()) var backCalls = 0 composeRule.setContent { @@ -595,7 +610,7 @@ class TimeLetterMindRecordCompletionAndroidTest { @Test fun weeklyReport_errorRetryThenEmptyAndComplete_preservesRequestedWeekContract() { val repository = - CompletionWeeklyReportRepository().apply { + FakeWeeklyReportRepository().apply { results.addLast(Result.failure(IllegalStateException("weekly offline"))) } val userRepository = @@ -766,135 +781,8 @@ private object CompletionFileMetadataRepository : FileMetadataRepository { override suspend fun getMimeType(uriString: String): String? = error("Unexpected MIME lookup: $uriString") } -private class CompletionDailyQuestionRepository( - var nextListGate: CompletableDeferred>>? = null, -) : DailyQuestionRepository { - val listResults = ArrayDeque>>() - val storedAnswers = mutableListOf() - val createCalls = mutableListOf() - val updateCalls = mutableListOf>() - var today = TodayDailyQuestion(71L, 71, "오늘의 테스트 질문", false) - var listCalls = 0 - private set - var todayCalls = 0 - private set - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> { - listCalls += 1 - val gate = nextListGate - nextListGate = null - return gate?.await() ?: listResults.removeFirstOrNull() ?: Result.success(storedAnswers.toList()) - } - - override suspend fun getToday(): Result { - todayCalls += 1 - return Result.success(today) - } - - override suspend fun create(payload: DailyQuestionCreatePayload): Result { - createCalls += payload - val createdId = 800L + createCalls.size - storedAnswers += - DailyQuestion( - dailyQuestionId = createdId, - title = today.content, - content = payload.content, - createdAt = "2026-08-22", - isDraft = payload.isDraft, - ) - today = today.copy(isAnswered = !payload.isDraft, isDraft = payload.isDraft) - return Result.success(createdId) - } - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result { - updateCalls += id to payload - storedAnswers.replaceAll { answer -> - if (answer.dailyQuestionId == id) { - answer.copy( - content = payload.content ?: answer.content, - isDraft = payload.isDraft ?: answer.isDraft, - ) - } else { - answer - } - } - return Result.success(id) - } - - override suspend fun delete(id: Long): Result { - storedAnswers.removeAll { it.dailyQuestionId == id } - return Result.success(Unit) - } -} - -private class CompletionDiaryRepository( - val drafts: MutableList, -) : DiaryRepository { - val listCalls = mutableListOf>() - val createCalls = mutableListOf() - val updateCalls = mutableListOf>() - - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result { - listCalls += yearMonth to draftOnly - val matching = drafts.filter { draftOnly == null || it.isDraft == draftOnly } - return Result.success( - DiaryList( - diaries = matching, - monthDiaryCount = matching.size, - weeklyDominantMood = matching.firstOrNull()?.todayMood, - ), - ) - } - - override suspend fun create(payload: DiaryCreatePayload): Result { - createCalls += payload - return Result.success(Unit) - } - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result { - updateCalls += id to payload - drafts.replaceAll { diary -> - if (diary.diaryId == id) { - diary.copy( - title = payload.title, - content = payload.content, - todayMood = payload.todayMood, - isDraft = payload.isDraft, - ) - } else { - diary - } - } - return Result.success(Unit) - } - - override suspend fun delete(id: Long): Result { - drafts.removeAll { it.diaryId == id } - return Result.success(Unit) - } -} - -private class CompletionWeeklyReportRepository : WeeklyReportRepository { - val results = ArrayDeque>() - val requestedDates = mutableListOf() - - override suspend fun getWeeklyReport(date: String): Result { - requestedDates += date - return requireNotNull(results.removeFirstOrNull()) { "Missing weekly response for $date" } - } -} +/** 이 파일의 데일리질문 시나리오가 공유하는 "오늘의 질문". `questionId` 를 단언하는 곳이 있다. */ +private fun completionToday() = TodayDailyQuestion(71L, 71, "오늘의 테스트 질문", false) private fun dailyQuestion( id: Long, diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt index ba27408d8d..24cac3064e 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestFakes.kt @@ -15,14 +15,6 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload -import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload -import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload -import com.afternote.feature.mindrecord.domain.model.DiaryList -import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload -import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -229,66 +221,6 @@ class FakeUserProfileRepository : UserProfileRepository { } } -class FakeDiaryRepository( - var listResult: Result = Result.success(DiaryList(emptyList(), 0, null)), -) : DiaryRepository { - val createdPayloads = mutableListOf() - val updatedPayloads = mutableListOf>() - val createResults = ArrayDeque>() - - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result = listResult - - override suspend fun create(payload: DiaryCreatePayload): Result { - createdPayloads += payload - return createResults.removeFirstOrNull() ?: Result.success(Unit) - } - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result { - updatedPayloads += id to payload - return Result.success(Unit) - } - - override suspend fun delete(id: Long): Result = Result.success(Unit) -} - -class FakeDailyQuestionRepository( - var todayResult: Result = - Result.success(TodayDailyQuestion(1L, 1, "오늘의 질문", false)), -) : DailyQuestionRepository { - val createdPayloads = mutableListOf() - val createResults = ArrayDeque>() - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = Result.success(emptyList()) - - override suspend fun getToday(): Result = todayResult - - override suspend fun create(payload: DailyQuestionCreatePayload): Result { - createdPayloads += payload - return createResults.removeFirstOrNull() ?: Result.success(CREATED_DAILY_QUESTION_ID) - } - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = Result.success(id) - - override suspend fun delete(id: Long): Result = Result.success(Unit) - - private companion object { - /** 서버가 돌려주는 `userDailyQuestionId` 자리 (#573). 값 자체에 의미는 없다. */ - const val CREATED_DAILY_QUESTION_ID = 1L - } -} - class FakeErrorReporter : ErrorReporter { val failures = mutableListOf>>() diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt index f4ac30b43b..3f8c89c280 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/test/TestHiltModules.kt @@ -13,6 +13,8 @@ import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepositor import com.afternote.feature.mindrecord.domain.repository.DiaryRepository import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import dagger.Module import dagger.Provides import dagger.hilt.components.SingletonComponent diff --git a/build-logic/src/main/kotlin/AndroidDomainConventionPlugin.kt b/build-logic/src/main/kotlin/AndroidDomainConventionPlugin.kt index b25ffed830..a4cbc1680f 100644 --- a/build-logic/src/main/kotlin/AndroidDomainConventionPlugin.kt +++ b/build-logic/src/main/kotlin/AndroidDomainConventionPlugin.kt @@ -1,5 +1,7 @@ +import com.android.build.api.dsl.LibraryExtension import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure class AndroidDomainConventionPlugin : Plugin { override fun apply(target: Project) { @@ -7,9 +9,18 @@ class AndroidDomainConventionPlugin : Plugin { pluginManager.apply("afternote.android.library") pluginManager.apply("afternote.android.hilt") + // 도메인 계약의 fake 정본은 계약 옆(src/testFixtures)에 둔다 — 유닛 테스트와 + // androidTest 가 각자 복사본을 들고 있으면 계약이 바뀔 때 고칠 곳이 갈라지고, + // 그중 일부만 고친 채로 초록을 보게 된다 (#1030). + extensions.configure { + testFixtures { + enable = true + } + } + afterNoteDependencies { project(":core:model") } } } -} \ No newline at end of file +} diff --git a/feature/home/presentation/build.gradle.kts b/feature/home/presentation/build.gradle.kts index 276890c769..d08d82a702 100644 --- a/feature/home/presentation/build.gradle.kts +++ b/feature/home/presentation/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // HomeTabViewModel 경합 테스트 — 가상 시간으로 viewModelScope 요청 순서를 제어한다. testImplementation(libs.coroutines.test) + testImplementation(testFixtures(projects.feature.mindrecord.domain)) // 로딩 중 배지가 «미완료» 로 확정되지 않는지는 실제로 그려 봐야 확인된다 (#698). testImplementation(libs.robolectric) diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt index 609c30d111..a2d54a7ec8 100644 --- a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeTabViewModelTest.kt @@ -12,15 +12,12 @@ import com.afternote.core.model.user.User import com.afternote.core.model.user.UserConnectedAccount import com.afternote.core.model.user.UserPushSetting import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase -import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload -import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.model.MindRecordCategory import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers @@ -87,7 +84,7 @@ class HomeTabViewModelTest { assertEquals(listOf("효기"), fixture.profile.savedUserNames) assertEquals(1, fixture.server.userRepository.profileCalls) assertEquals(1, fixture.server.userRepository.receiverCalls) - assertEquals(1, fixture.server.diaryRepository.getListCalls) + assertEquals(1, fixture.server.diaryRepository.listQueries.size) assertEquals(1, fixture.server.dailyQuestionRepository.getTodayCalls) } @@ -270,14 +267,26 @@ private class Fixture { private class FakeHomeRepositories { val userRepository = FakeUserRepository() - val diaryRepository = FakeDiaryRepository() - val dailyQuestionRepository = FakeDailyQuestionRepository() + + private val diaryResults = ArrayDeque>>() + val diaryRepository = + FakeDiaryRepository.strict().apply { + onGetList = { _, _ -> diaryResults.takeNext("DiaryRepository.getList").await() } + } + + /** 완료 시점을 테스트가 쥐고 있어야 병렬 조회의 경합 순서를 만들 수 있다. */ + private val questionResults = ArrayDeque>>() + + val dailyQuestionRepository = + FakeDailyQuestionRepository.strict().apply { + onGetToday = { questionResults.takeNext("DailyQuestionRepository.getToday").await() } + } fun enqueueRequest(): PendingHomeRequest = PendingHomeRequest().also { request -> userRepository.enqueue(request.profile, request.receivers) - diaryRepository.enqueue(request.diary) - dailyQuestionRepository.enqueue(request.question) + diaryResults.addLast(request.diary) + questionResults.addLast(request.question) } } @@ -413,64 +422,6 @@ private class FakeUserRepository : UserRepository { ): ReceiverDeliveryConditions = unexpected("updateReceiverDeliveryConditions") } -private class FakeDiaryRepository : DiaryRepository { - private val results = ArrayDeque>>() - - var getListCalls: Int = 0 - private set - - fun enqueue(result: CompletableDeferred>) { - results.addLast(result) - } - - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result { - getListCalls++ - return results.takeNext("DiaryRepository.getList").await() - } - - override suspend fun create(payload: DiaryCreatePayload): Result = unexpected("DiaryRepository.create") - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result = unexpected("DiaryRepository.update") - - override suspend fun delete(id: Long): Result = unexpected("DiaryRepository.delete") -} - -private class FakeDailyQuestionRepository : DailyQuestionRepository { - private val results = ArrayDeque>>() - - var getTodayCalls: Int = 0 - private set - - fun enqueue(result: CompletableDeferred>) { - results.addLast(result) - } - - override suspend fun getToday(): Result { - getTodayCalls++ - return results.takeNext("DailyQuestionRepository.getToday").await() - } - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = unexpected("DailyQuestionRepository.getList") - - override suspend fun create(payload: DailyQuestionCreatePayload): Result = unexpected("DailyQuestionRepository.create") - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = unexpected("DailyQuestionRepository.update") - - override suspend fun delete(id: Long): Result = unexpected("DailyQuestionRepository.delete") -} - private class FakeUserProfileRepository : UserProfileRepository { var cachedUserName: String? = null val savedUserNames = mutableListOf() diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt new file mode 100644 index 0000000000..0d27b391e0 --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDailyQuestionRepository.kt @@ -0,0 +1,134 @@ +package com.afternote.feature.mindrecord.domain.testing + +import com.afternote.feature.mindrecord.domain.model.DailyQuestion +import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload +import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload +import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion +import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository + +/** + * [DailyQuestionRepository] fake 정본. + * + * 기본 동작은 **메모리 저장소**다 — 생성하면 [answers] 에 쌓이고, 수정·삭제가 그 목록에 + * 반영되며, 조회는 그 상태를 돌려준다. 호출은 전부 기록되므로 테스트는 사후에 단언한다. + * + * 기본 동작으로 안 되는 시나리오(경합을 만들려고 조회를 붙잡아 두기, 특정 호출을 실패로 + * 밀어 넣기, "이 시나리오에서 호출되면 안 됨" 을 그 자리에서 터뜨리기)는 `onX` 람다로 + * 갈아끼운다. 람다가 `null` 이면 기본 동작이다. + * + * 시나리오마다 클래스를 새로 만들지 않는다 — 계약이 바뀔 때 고칠 곳이 갈라지는 게 + * androidTest 컴파일 파손의 원인이었다 (#936, #1022, #1030). + */ +class FakeDailyQuestionRepository( + initialAnswers: List = emptyList(), + var today: TodayDailyQuestion = DEFAULT_TODAY, + var onGetList: (suspend (String?, Boolean?) -> Result>)? = null, + var onGetToday: (suspend () -> Result)? = null, + var onCreate: (suspend (DailyQuestionCreatePayload) -> Result)? = null, + var onUpdate: (suspend (Long, DailyQuestionUpdatePayload) -> Result)? = null, + var onDelete: (suspend (Long) -> Result)? = null, +) : DailyQuestionRepository { + /** 메모리 저장소. 테스트가 직접 들여다보거나 조작해도 된다. */ + val answers: MutableList = initialAnswers.toMutableList() + + val listQueries = mutableListOf() + val createdPayloads = mutableListOf() + val updatedPayloads = mutableListOf>() + val deletedIds = mutableListOf() + + var getTodayCalls: Int = 0 + private set + + /** 다음에 붙일 식별자. 생성 fake 가 돌려주는 `userDailyQuestionId` 자리다 (#573). */ + var nextCreatedId: Long = FIRST_CREATED_ID + + data class ListQuery( + val date: String?, + val draftOnly: Boolean?, + ) + + override suspend fun getList( + date: String?, + draftOnly: Boolean?, + ): Result> { + listQueries += ListQuery(date, draftOnly) + onGetList?.let { return it(date, draftOnly) } + // `draftOnly` 를 생략하면 서버는 제출 완료만 내려준다 — 그 계약을 그대로 흉내 낸다. + val matching = + when (draftOnly) { + null -> answers.filterNot { it.isDraft } + else -> answers.filter { it.isDraft == draftOnly } + } + return Result.success(matching) + } + + override suspend fun getToday(): Result { + getTodayCalls += 1 + onGetToday?.let { return it() } + return Result.success(today) + } + + override suspend fun create(payload: DailyQuestionCreatePayload): Result { + createdPayloads += payload + onCreate?.let { return it(payload) } + val id = nextCreatedId + nextCreatedId += 1 + answers += + DailyQuestion( + dailyQuestionId = id, + title = today.content, + content = payload.content, + createdAt = DEFAULT_CREATED_AT, + isDraft = payload.isDraft, + ) + today = today.copy(isAnswered = !payload.isDraft, isDraft = payload.isDraft) + return Result.success(id) + } + + override suspend fun update( + id: Long, + payload: DailyQuestionUpdatePayload, + ): Result { + updatedPayloads += id to payload + onUpdate?.let { return it(id, payload) } + answers.replaceAll { answer -> + if (answer.dailyQuestionId == id) { + answer.copy( + content = payload.content ?: answer.content, + isDraft = payload.isDraft ?: answer.isDraft, + ) + } else { + answer + } + } + return Result.success(id) + } + + override suspend fun delete(id: Long): Result { + deletedIds += id + onDelete?.let { return it(id) } + answers.removeAll { it.dailyQuestionId == id } + return Result.success(Unit) + } + + companion object { + const val FIRST_CREATED_ID = 1L + private const val DEFAULT_CREATED_AT = "2026-08-22" + private val DEFAULT_TODAY = TodayDailyQuestion(1L, 1, "오늘의 질문", false) + + /** + * 모든 호출을 그 자리에서 실패시키는 fake. 시나리오가 실제로 쓰는 것만 `onX` 로 연다. + * + * 메모리 저장소 기본 동작이 "안 불릴 줄 알았던 호출" 을 조용히 받아 주면, 종전 + * 시나리오별 fake 가 `error("호출되면 안 됨")` 으로 지키던 경계가 사라진다. + */ + fun strict(): FakeDailyQuestionRepository = + FakeDailyQuestionRepository( + onGetList = { _, _ -> unexpectedCall("DailyQuestionRepository.getList") }, + onGetToday = { unexpectedCall("DailyQuestionRepository.getToday") }, + onCreate = { unexpectedCall("DailyQuestionRepository.create") }, + onUpdate = { _, _ -> unexpectedCall("DailyQuestionRepository.update") }, + onDelete = { unexpectedCall("DailyQuestionRepository.delete") }, + ) + } +} diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt new file mode 100644 index 0000000000..5f232eb859 --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeDiaryRepository.kt @@ -0,0 +1,96 @@ +package com.afternote.feature.mindrecord.domain.testing + +import com.afternote.feature.mindrecord.domain.model.Diary +import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload +import com.afternote.feature.mindrecord.domain.model.DiaryList +import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload +import com.afternote.feature.mindrecord.domain.repository.DiaryRepository + +/** + * [DiaryRepository] fake 정본. 규약은 [FakeDailyQuestionRepository] 와 같다 — + * 기본은 메모리 저장소, 표현 못 하는 시나리오만 `onX` 람다로 갈아끼운다 (#1030). + * + * 조회는 `yearMonth` 로 거르지 **않는다**. 테스트가 넣는 일기는 대개 조회 대상 달의 + * 것이고, 달을 넘나드는 시나리오는 `onGetList` 로 직접 답하는 편이 읽기 쉽다. + */ +class FakeDiaryRepository( + initialDiaries: List = emptyList(), + var onGetList: (suspend (String, Boolean?) -> Result)? = null, + var onCreate: (suspend (DiaryCreatePayload) -> Result)? = null, + var onUpdate: (suspend (Long, DiaryUpdatePayload) -> Result)? = null, + var onDelete: (suspend (Long) -> Result)? = null, +) : DiaryRepository { + val diaries: MutableList = initialDiaries.toMutableList() + + val listQueries = mutableListOf() + val createdPayloads = mutableListOf() + val updatedPayloads = mutableListOf>() + val deletedIds = mutableListOf() + + data class ListQuery( + val yearMonth: String, + val draftOnly: Boolean?, + ) + + override suspend fun getList( + yearMonth: String, + draftOnly: Boolean?, + ): Result { + listQueries += ListQuery(yearMonth, draftOnly) + onGetList?.let { return it(yearMonth, draftOnly) } + val matching = diaries.filter { draftOnly == null || it.isDraft == draftOnly } + return Result.success( + DiaryList( + diaries = matching, + monthDiaryCount = matching.size, + weeklyDominantMood = matching.firstOrNull()?.todayMood, + ), + ) + } + + override suspend fun create(payload: DiaryCreatePayload): Result { + createdPayloads += payload + onCreate?.let { return it(payload) } + return Result.success(Unit) + } + + override suspend fun update( + id: Long, + payload: DiaryUpdatePayload, + ): Result { + updatedPayloads += id to payload + onUpdate?.let { return it(id, payload) } + diaries.replaceAll { diary -> + if (diary.diaryId == id) { + // date·imageUrl 은 수정 요청 계약에서 빠졌다 (#955) — 기존 값을 유지한다. + diary.copy( + title = payload.title, + content = payload.content, + todayMood = payload.todayMood, + isDraft = payload.isDraft, + ) + } else { + diary + } + } + return Result.success(Unit) + } + + override suspend fun delete(id: Long): Result { + deletedIds += id + onDelete?.let { return it(id) } + diaries.removeAll { it.diaryId == id } + return Result.success(Unit) + } + + companion object { + /** 모든 호출을 실패시키고, 시나리오가 실제로 쓰는 것만 `onX` 로 연다. */ + fun strict(): FakeDiaryRepository = + FakeDiaryRepository( + onGetList = { _, _ -> unexpectedCall("DiaryRepository.getList") }, + onCreate = { unexpectedCall("DiaryRepository.create") }, + onUpdate = { _, _ -> unexpectedCall("DiaryRepository.update") }, + onDelete = { unexpectedCall("DiaryRepository.delete") }, + ) + } +} diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeMindRecordReceiverRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeMindRecordReceiverRepository.kt new file mode 100644 index 0000000000..ed38f57dd5 --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeMindRecordReceiverRepository.kt @@ -0,0 +1,28 @@ +package com.afternote.feature.mindrecord.domain.testing + +import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords +import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository + +/** + * [MindRecordReceiverRepository] fake 정본 (#1030). + * + * 기본은 [result] 를 그대로 돌려준다. 완료 시점을 테스트가 쥐어야 하는 경합 시나리오는 + * `onGetAll` 로 갈아끼운다. + */ +class FakeMindRecordReceiverRepository( + var result: Result = Result.success(EMPTY_RECORDS), + var onGetAll: (suspend () -> Result)? = null, +) : MindRecordReceiverRepository { + var getAllCalls: Int = 0 + private set + + override suspend fun getAll(): Result { + getAllCalls += 1 + onGetAll?.let { return it() } + return result + } + + companion object { + private val EMPTY_RECORDS = ReceiverMindRecords(dailyQuestions = emptyList(), diaries = emptyList()) + } +} diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeWeeklyReportRepository.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeWeeklyReportRepository.kt new file mode 100644 index 0000000000..7ef1e0f82f --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/FakeWeeklyReportRepository.kt @@ -0,0 +1,21 @@ +package com.afternote.feature.mindrecord.domain.testing + +import com.afternote.feature.mindrecord.domain.model.WeeklyReport +import com.afternote.feature.mindrecord.domain.repository.WeeklyReportRepository + +/** + * [WeeklyReportRepository] fake 정본 (#1030). + * + * 이 화면의 테스트는 "요청한 주" 와 "응답 순서" 를 함께 본다 — 응답을 [results] 로 + * 밀어 넣고 [requestedDates] 로 무엇을 요청했는지 확인한다. 큐가 비면 실패시킨다: + * 조용히 빈 리포트를 돌려주면 요청 횟수가 어긋난 것을 놓친다. + */ +class FakeWeeklyReportRepository : WeeklyReportRepository { + val results = ArrayDeque>() + val requestedDates = mutableListOf() + + override suspend fun getWeeklyReport(date: String): Result { + requestedDates += date + return requireNotNull(results.removeFirstOrNull()) { "$date 주간 리포트 응답이 큐에 없다" } + } +} diff --git a/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/UnexpectedCall.kt b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/UnexpectedCall.kt new file mode 100644 index 0000000000..553b0753fa --- /dev/null +++ b/feature/mindrecord/domain/src/testFixtures/kotlin/com/afternote/feature/mindrecord/domain/testing/UnexpectedCall.kt @@ -0,0 +1,10 @@ +package com.afternote.feature.mindrecord.domain.testing + +/** + * "이 시나리오에서 호출되면 안 됨" 을 그 자리에서 터뜨린다. + * + * fake 를 하나로 접으면 기본 동작이 관대해져, 안 불릴 줄 알았던 호출이 조용히 통과할 수 + * 있다. 시나리오별 fake 가 `error(...)` 로 지키던 경계를 `onX = { unexpectedCall(...) }` + * 로 그대로 옮긴다. + */ +fun unexpectedCall(method: String): Nothing = error("$method 는 이 시나리오에서 호출되면 안 됨") diff --git a/feature/mindrecord/presentation/build.gradle.kts b/feature/mindrecord/presentation/build.gradle.kts index 94a78ba931..10c31a6a29 100644 --- a/feature/mindrecord/presentation/build.gradle.kts +++ b/feature/mindrecord/presentation/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { } testImplementation(libs.coroutines.test) + testImplementation(testFixtures(projects.feature.mindrecord.domain)) // 컴파일된 리소스로 문구를 검증한다 — aapt2 의 앞뒤 공백 제거는 소스 XML 만 봐서는 잡히지 않는다 (#732). // 열린 주차 메뉴의 스크롤·최하단 선택도 JVM 에서 그대로 재현한다 (#729). 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 eb413dc58d..fec5dda4d3 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 @@ -3,14 +3,10 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import androidx.lifecycle.SavedStateHandle import com.afternote.core.domain.repository.PhotoUploadRepository import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload -import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload -import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList -import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion -import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository -import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking @@ -56,7 +52,7 @@ class DailyQuestionWriteViewModelTest { Result.success(todayQuestion(isDraft = true)) } }, - onGetList = { Result.success(listOf(draft(content = "서버에 남아 있던 옛 임시저장본"))) }, + onGetList = { _, _ -> Result.success(listOf(draft(content = "서버에 남아 있던 옛 임시저장본"))) }, ) val viewModel = DailyQuestionWriteViewModel( @@ -80,7 +76,7 @@ class DailyQuestionWriteViewModelTest { val repository = FakeDailyQuestionRepository( onGetToday = { Result.success(todayQuestion(isDraft = true)) }, - onGetList = { Result.success(listOf(draft(content = "이어쓸 본문"))) }, + onGetList = { _, _ -> Result.success(listOf(draft(content = "이어쓸 본문"))) }, ) val viewModel = @@ -108,7 +104,7 @@ class DailyQuestionWriteViewModelTest { Result.success(todayQuestion(isDraft = true)) } }, - onGetList = { Result.success(listOf(draft(content = "옛 본문"))) }, + onGetList = { _, _ -> Result.success(listOf(draft(content = "옛 본문"))) }, ) val viewModel = DailyQuestionWriteViewModel( @@ -133,7 +129,7 @@ class DailyQuestionWriteViewModelTest { val repository = FakeDailyQuestionRepository( onGetToday = { Result.success(todayQuestion(isDraft = false)) }, - onGetList = { Result.success(emptyList()) }, + onGetList = { _, _ -> Result.success(emptyList()) }, ) val viewModel = DailyQuestionWriteViewModel( @@ -157,7 +153,7 @@ class DailyQuestionWriteViewModelTest { val repository = FakeDailyQuestionRepository( onGetToday = { Result.success(todayQuestion(isDraft = true)) }, - onGetList = { Result.success(listOf(draft(content = html))) }, + onGetList = { _, _ -> Result.success(listOf(draft(content = html))) }, ) val viewModel = @@ -176,7 +172,7 @@ class DailyQuestionWriteViewModelTest { val repository = FakeDailyQuestionRepository( onGetToday = { Result.failure(IllegalStateException("네트워크 실패")) }, - onGetList = { Result.success(emptyList()) }, + onGetList = { _, _ -> Result.success(emptyList()) }, ) val viewModel = DailyQuestionWriteViewModel( @@ -190,7 +186,7 @@ class DailyQuestionWriteViewModelTest { viewModel.submit() assertTrue(viewModel.uiState.value.submitState is SubmitState.Failed) - assertEquals(0, repository.createCallCount) + assertEquals(0, repository.createdPayloads.size) } private fun todayQuestion(isDraft: Boolean) = @@ -222,7 +218,7 @@ class DailyQuestionWriteViewModelTest { onGetToday = { Result.success(todayQuestion(isDraft = false)) }, onCreate = { payload -> sentContent = payload.content - Result.success(Unit) + Result.success(FakeDailyQuestionRepository.FIRST_CREATED_ID) }, ) val viewModel = @@ -252,7 +248,7 @@ class DailyQuestionWriteViewModelTest { onGetToday = { Result.success(todayQuestion(isDraft = false)) }, onCreate = { payload -> sentContent = payload.content - Result.success(Unit) + Result.success(FakeDailyQuestionRepository.FIRST_CREATED_ID) }, ) val viewModel = DailyQuestionWriteViewModel(SavedStateHandle(emptyMap()), repository, NoopPhotoUploadRepository, noopDraftLoader()) @@ -264,36 +260,6 @@ class DailyQuestionWriteViewModelTest { } } -/** 미지정 경로 호출은 error 로 드러낸다 (core:data 의 Fake 들과 같은 규칙). */ -private class FakeDailyQuestionRepository( - private val onGetToday: () -> Result, - private val onGetList: () -> Result> = { Result.success(emptyList()) }, - private val onCreate: (DailyQuestionCreatePayload) -> Result = { Result.success(Unit) }, -) : DailyQuestionRepository { - var createCallCount = 0 - private set - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = onGetList() - - override suspend fun getToday(): Result = onGetToday() - - override suspend fun create(payload: DailyQuestionCreatePayload): Result { - createCallCount += 1 - // 서버가 돌려주는 "내 답변" 식별자 (#573). 주입된 동작이 실패를 흉내 내면 그것을 쓴다. - return onCreate(payload).map { CREATED_ANSWER_ID } - } - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = error("update 는 이 시나리오에서 호출되면 안 됨") - - override suspend fun delete(id: Long): Result = error("delete 는 이 시나리오에서 호출되면 안 됨") -} - private object NoopPhotoUploadRepository : PhotoUploadRepository { override suspend fun upload( uriString: String, @@ -301,45 +267,17 @@ private object NoopPhotoUploadRepository : PhotoUploadRepository { ): Result = error("upload 는 이 시나리오에서 호출되면 안 됨") } -private const val CREATED_ANSWER_ID = 19L - /** 툴바 카운트는 이 테스트의 관심사가 아니다 — 0건으로 고정한다 (#769). */ internal fun noopDraftLoader() = MindRecordDraftLoader( - diaryRepository = EmptyDraftDiaryRepository, - dailyQuestionRepository = EmptyDraftDailyQuestionRepository, + diaryRepository = + FakeDiaryRepository.strict().apply { + onGetList = { _, _ -> + Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + } + }, + dailyQuestionRepository = + FakeDailyQuestionRepository.strict().apply { + onGetList = { _, _ -> Result.success(emptyList()) } + }, ) - -internal object EmptyDraftDiaryRepository : DiaryRepository { - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) - - override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result = error("호출되면 안 됨") - - override suspend fun delete(id: Long): Result = error("호출되면 안 됨") -} - -internal object EmptyDraftDailyQuestionRepository : DailyQuestionRepository { - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = Result.success(emptyList()) - - override suspend fun getToday(): Result = error("호출되면 안 됨") - - override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("호출되면 안 됨") - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = error("호출되면 안 됨") - - override suspend fun delete(id: Long): Result = error("호출되면 안 됨") -} 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 e487320945..f51f92c62d 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 @@ -1,15 +1,13 @@ package com.afternote.feature.mindrecord.presentation.viewmodel import com.afternote.feature.mindrecord.domain.model.DailyQuestion -import com.afternote.feature.mindrecord.domain.model.DailyQuestionCreatePayload -import com.afternote.feature.mindrecord.domain.model.DailyQuestionUpdatePayload import com.afternote.feature.mindrecord.domain.model.Diary -import com.afternote.feature.mindrecord.domain.model.DiaryCreatePayload import com.afternote.feature.mindrecord.domain.model.DiaryList -import com.afternote.feature.mindrecord.domain.model.DiaryUpdatePayload import com.afternote.feature.mindrecord.domain.model.TodayDailyQuestion import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDailyQuestionRepository +import com.afternote.feature.mindrecord.domain.testing.FakeDiaryRepository import com.afternote.feature.mindrecord.presentation.reporting.RecordingErrorReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -45,10 +43,10 @@ class DraftListDeleteTest { // 화면은 사용자에게 알리지만, 종전에는 콘솔에 아무 흔적도 남지 않아 릴리즈에서 // «지워지지 않는다» 는 제보를 받아도 재현 지점을 찾을 수 없었다 (#964). val repository = - FakeDailyQuestionDraftRepository( - drafts = listOf(dailyQuestion(id = 1L)), - deleteResult = Result.failure(IllegalStateException("서버 거절")), - removesOnDelete = false, + FakeDailyQuestionRepository( + initialAnswers = listOf(dailyQuestion(id = 1L)), + // 서버가 거절해 항목이 그대로 남는 상황 — 저장소에서 지우지 않는다. + onDelete = { Result.failure(IllegalStateException("서버 거절")) }, ) val reporter = RecordingErrorReporter() val viewModel = viewModel(repository, reporter) @@ -62,11 +60,10 @@ class DraftListDeleteTest { fun `삭제에 실패하고 항목이 남아 있으면 완료가 아니라 실패로 알린다`() { val stubborn = dailyQuestion(id = 1L) val repository = - FakeDailyQuestionDraftRepository( - drafts = listOf(stubborn), - // 서버가 거절해 항목이 그대로 남는 상황. - deleteResult = Result.failure(IllegalStateException("서버 거절")), - removesOnDelete = false, + FakeDailyQuestionRepository( + initialAnswers = listOf(stubborn), + // 서버가 거절해 항목이 그대로 남는 상황 — 저장소에서 지우지 않는다. + onDelete = { Result.failure(IllegalStateException("서버 거절")) }, ) val viewModel = viewModel(repository) val target = (viewModel.uiState.value as DraftListUiState.Success).items @@ -83,12 +80,14 @@ class DraftListDeleteTest { @Test fun `삭제가 실패했어도 항목이 사라졌으면 완료로 본다`() { val repository = - FakeDailyQuestionDraftRepository( - drafts = listOf(dailyQuestion(id = 1L)), - // 이미 없는 항목을 지우려다 404 — 사용자가 원한 결과는 이뤄졌다. - deleteResult = Result.failure(IllegalStateException("404")), - removesOnDelete = true, - ) + FakeDailyQuestionRepository(initialAnswers = listOf(dailyQuestion(id = 1L))) + .apply { + // 이미 없는 항목을 지우려다 404 — 실패로 답하지만 항목은 사라진다. + onDelete = { id -> + answers.removeAll { it.dailyQuestionId == id } + Result.failure(IllegalStateException("404")) + } + } val viewModel = viewModel(repository) val target = (viewModel.uiState.value as DraftListUiState.Success).items @@ -102,11 +101,8 @@ class DraftListDeleteTest { @Test fun `모두 성공하면 완료로 알린다`() { val repository = - FakeDailyQuestionDraftRepository( - drafts = listOf(dailyQuestion(id = 1L)), - deleteResult = Result.success(Unit), - removesOnDelete = true, - ) + // 성공 삭제는 픽스처 기본 동작(저장소에서 제거 + success)이 그대로 맞다. + FakeDailyQuestionRepository(initialAnswers = listOf(dailyQuestion(id = 1L))) val viewModel = viewModel(repository) val target = (viewModel.uiState.value as DraftListUiState.Success).items @@ -126,10 +122,10 @@ class DraftListDeleteTest { // 저장소를 넘겨 종전과 같은 조회 경로를 그대로 태운다. loader = MindRecordDraftLoader( - diaryRepository = EmptyDiaryDraftRepository, + diaryRepository = emptyDiaryDrafts(), dailyQuestionRepository = dailyQuestionRepository, ), - diaryRepository = EmptyDiaryDraftRepository, + diaryRepository = emptyDiaryDrafts(), dailyQuestionRepository = dailyQuestionRepository, errorReporter = errorReporter, ) @@ -144,45 +140,10 @@ class DraftListDeleteTest { ) } -private class FakeDailyQuestionDraftRepository( - drafts: List, - private val deleteResult: Result, - private val removesOnDelete: Boolean, -) : DailyQuestionRepository { - private val remaining = drafts.toMutableList() - - override suspend fun getList( - date: String?, - draftOnly: Boolean?, - ): Result> = Result.success(remaining.toList()) - - override suspend fun getToday(): Result = error("호출되면 안 됨") - - override suspend fun create(payload: DailyQuestionCreatePayload): Result = error("호출되면 안 됨") - - override suspend fun update( - id: Long, - payload: DailyQuestionUpdatePayload, - ): Result = error("호출되면 안 됨") - - override suspend fun delete(id: Long): Result { - if (removesOnDelete) remaining.removeAll { it.dailyQuestionId == id } - return deleteResult +/** 일기 임시저장은 이 테스트의 관심사가 아니다 — 0건으로 고정하고 나머지 호출은 막는다. */ +private fun emptyDiaryDrafts() = + FakeDiaryRepository.strict().apply { + onGetList = { _, _ -> + Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) + } } -} - -private object EmptyDiaryDraftRepository : DiaryRepository { - override suspend fun getList( - yearMonth: String, - draftOnly: Boolean?, - ): Result = Result.success(DiaryList(diaries = emptyList(), monthDiaryCount = 0, weeklyDominantMood = null)) - - override suspend fun create(payload: DiaryCreatePayload): Result = error("호출되면 안 됨") - - override suspend fun update( - id: Long, - payload: DiaryUpdatePayload, - ): Result = error("호출되면 안 됨") - - override suspend fun delete(id: Long): Result = error("호출되면 안 됨") -} diff --git a/feature/receiver/presentation/build.gradle.kts b/feature/receiver/presentation/build.gradle.kts index aa89f9ba57..ec763b0ffd 100644 --- a/feature/receiver/presentation/build.gradle.kts +++ b/feature/receiver/presentation/build.gradle.kts @@ -35,6 +35,7 @@ dependencies { implementation(libs.androidx.paging.compose) testImplementation(libs.coroutines.test) + testImplementation(testFixtures(projects.feature.mindrecord.domain)) testImplementation(libs.robolectric) // 실패 표기가 «레이아웃을 유지하는지» 는 실제로 그려 봐야 확인된다 (#952). diff --git a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt index d9277aa632..d44aeeb922 100644 --- a/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt +++ b/feature/receiver/presentation/src/test/kotlin/com/afternote/feature/receiver/presentation/home/ReceiverHomeViewModelTest.kt @@ -3,7 +3,7 @@ package com.afternote.feature.receiver.presentation.home import com.afternote.core.common.reporting.ErrorReporter import com.afternote.feature.mindrecord.domain.model.MindRecordType import com.afternote.feature.mindrecord.domain.model.ReceiverMindRecords -import com.afternote.feature.mindrecord.domain.repository.MindRecordReceiverRepository +import com.afternote.feature.mindrecord.domain.testing.FakeMindRecordReceiverRepository import com.afternote.feature.receiver.domain.model.AfterNotesListResult import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail import com.afternote.feature.receiver.domain.model.ReceivedExportBundle @@ -183,12 +183,6 @@ private class FakeReceiverRepository : ReceiverRepository { override suspend fun loadSenderMessage(): Result = message } -private class FakeMindRecordReceiverRepository : MindRecordReceiverRepository { - var result: Result = Result.success(mindRecords(0, 0)) - - override suspend fun getAll(): Result = result -} - private class FakeReceiverTimeLetterRepository : ReceiverTimeLetterRepository { var result: Result = Result.success(timeLetters(0))