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
7 changes: 5 additions & 2 deletions .github/workflows/unit-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ name: Unit Test
# - :koverXmlReportCi / :koverHtmlReportCi : Android debug + 순수 JVM 테스트와 aggregate coverage
# - :konsist:test : 순수 JVM 모듈(testDebugUnitTest 미보유)이라 별도 명시. 레이어 의존 아키텍처 가드.
# - :build-logic:test : included build 라 루트 태스크에 안 묶여 별도 명시. release 키 가드(TestKit) 회귀 가드.
# - :app:compileDebugAndroidTestKotlin : androidTest 소스셋 컴파일 가드. GMD 스위트는 PR 트리거 없이
# 화·금 스케줄로만 돌아(android-managed-device.yml), main 코드 변경이 androidTest 를 깨뜨려도
# 머지 전 어디서도 검출되지 않았다(#936 — develop 컴파일 실패 44건). 에뮬레이터 없이 컴파일만 검증.
# lint.yml 과 setup 이 거의 동일하지만 분리 — lint 실패가 test 결과를 가리지 않도록 fail-fast 독립.

# base 필터를 두지 않는다 — base 변경(feat/* → develop)은 pull_request 기본 activity type 에
Expand Down Expand Up @@ -55,8 +58,8 @@ jobs:
- name: Set up CI-only app configuration
uses: ./.github/actions/setup-ci-config

- name: Run unit tests and aggregate Kover reports
run: ./gradlew :koverXmlReportCi :koverHtmlReportCi :konsist:test :build-logic:test --continue --parallel --build-cache
- name: Run unit tests, androidTest compile guard, and aggregate Kover reports
run: ./gradlew :koverXmlReportCi :koverHtmlReportCi :konsist:test :build-logic:test :app:compileDebugAndroidTestKotlin --continue --parallel --build-cache

- name: Upload Kover coverage reports
id: kover-artifact
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import java.time.LocalDate
import java.util.concurrent.atomic.AtomicInteger
import com.afternote.feature.afternote.presentation.R as AfternoteFeatureR

@RunWith(AndroidJUnit4::class)
class AfternoteAuthorExtendedAndroidTest {
Expand Down Expand Up @@ -120,9 +121,9 @@ class AfternoteAuthorExtendedAndroidTest {
Pager(PagingConfig(pageSize = 20)) { pagingSource }.flow
repository.listFlows[AfternoteType.SOCIAL_NETWORK] = flowOf(PagingData.empty())
val viewModel = AfternoteHomeViewModel(repository)
val accountRoutes = mutableListOf<String>()
val galleryRoutes = mutableListOf<String>()
val memorialRoutes = mutableListOf<String>()
val accountRoutes = mutableListOf<Long>()
val galleryRoutes = mutableListOf<Long>()
val memorialRoutes = mutableListOf<Long>()
val addRoutes = mutableListOf<AfternoteType?>()

composeRule.setContent {
Expand Down Expand Up @@ -157,9 +158,9 @@ class AfternoteAuthorExtendedAndroidTest {
.onNodeWithContentDescription("추억 노트")
.performScrollTo()
.performClick()
assertEquals(listOf("101"), accountRoutes)
assertEquals(listOf("102"), galleryRoutes)
assertEquals(listOf("103"), memorialRoutes)
assertEquals(listOf(101L), accountRoutes)
assertEquals(listOf(102L), galleryRoutes)
assertEquals(listOf(103L), memorialRoutes)

composeRule.onNodeWithText("소셜네트워크").performClick()
composeRule.waitUntil(timeoutMillis = 5_000) {
Expand Down Expand Up @@ -413,7 +414,7 @@ private fun AuthorDetailForDelete(
is AfternoteDetailDeleteResult.Failed -> {
val message =
resources.getString(
result.messageRes ?: R.string.afternote_detail_delete_failed,
result.messageRes ?: AfternoteFeatureR.string.afternote_detail_delete_failed,
)
scope.launch { snackbarHostState.showSnackbar(message) }
viewModel.onDeleteResultConsumed()
Expand Down Expand Up @@ -539,7 +540,7 @@ private fun detailViewModel(
itemId: Long,
): AfternoteDetailViewModel =
AfternoteDetailViewModel(
savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId.toString())),
savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId)),
afternoteRepository = repository,
userRepository = FakeUserRepository(),
errorReporter = FakeErrorReporter(),
Expand All @@ -550,7 +551,7 @@ private fun editorViewModel(
itemId: Long,
): AfternoteEditorViewModel =
AfternoteEditorViewModel(
savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId.toString())),
savedStateHandle = SavedStateHandle(mapOf("itemId" to itemId)),
authorReceiverRepository = ExtendedAuthorReceiverRepository(),
afternoteRepository = repository,
memorialThumbnailUploadRepository =
Expand Down Expand Up @@ -602,19 +603,19 @@ private fun editorViewModel(
private fun authorListItems(): List<ListItem> =
listOf(
ListItem(
id = "101",
id = 101L,
serviceName = "Instagram",
date = "2026.08.22",
type = AfternoteType.SOCIAL_NETWORK,
),
ListItem(
id = "102",
id = 102L,
serviceName = "Google Drive",
date = "2026.08.22",
type = AfternoteType.GALLERY_AND_FILES,
),
ListItem(
id = "103",
id = 103L,
serviceName = "추억 노트",
date = "2026.08.22",
type = AfternoteType.MEMORIAL,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,13 @@ import com.afternote.core.ui.theme.AfternoteTheme
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.receiver.domain.error.ReceiverEmailAuthException
import com.afternote.feature.receiver.domain.model.AfterNoteListItem
import com.afternote.feature.receiver.domain.model.AfterNotesListResult
import com.afternote.feature.receiver.domain.model.DeliveryVerification
import com.afternote.feature.receiver.domain.model.DeliveryVerificationStatus
import com.afternote.feature.receiver.domain.model.LoadCountResult
import com.afternote.feature.receiver.domain.model.ReceivedAfternoteDetail
import com.afternote.feature.receiver.domain.model.ReceivedExportBundle
import com.afternote.feature.receiver.domain.model.ReceivedPlaylistDetail
Expand All @@ -79,6 +80,9 @@ import com.afternote.feature.receiver.presentation.home.ReceiverHomeViewModel
import com.afternote.feature.receiver.presentation.home.model.ReceiverHomeUiState
import com.afternote.feature.receiver.presentation.navigation.ReceiverNavActions
import com.afternote.feature.receiver.presentation.navigation.model.ReceiverRoute
import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetter
import com.afternote.feature.timeletter.domain.model.ReceivedTimeLetterList
import com.afternote.feature.timeletter.domain.repository.ReceiverTimeLetterRepository
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import kotlinx.coroutines.CompletableDeferred
Expand Down Expand Up @@ -230,10 +234,27 @@ class ReceiverRuntimeCompletionAndroidTest {
@Test
fun receiverHome_allFailureThenRetryPartialSuccess_keepsAvailableSectionsAndReportsBothStages() {
val repository = CompletionReceiverRepository()
val allFailureAttempt = repository.enqueueHomeAttempt()
val partialAttempt = repository.enqueueHomeAttempt()
val mindRecordRepository = CompletionMindRecordReceiverRepository()
val timeLetterRepository = CompletionReceiverTimeLetterRepository()

fun homeCallCounts(): List<Int> =
listOf(
repository.afterNotesCalls,
mindRecordRepository.getAllCalls,
timeLetterRepository.listCalls,
repository.senderMessageCalls,
)

val allFailureAttempt = enqueueHomeAttempt(repository, mindRecordRepository, timeLetterRepository)
val partialAttempt = enqueueHomeAttempt(repository, mindRecordRepository, timeLetterRepository)
val reporter = FakeErrorReporter()
val viewModel = ReceiverHomeViewModel(repository, reporter)
val viewModel =
ReceiverHomeViewModel(
receiverRepository = repository,
mindRecordReceiverRepository = mindRecordRepository,
receiverTimeLetterRepository = timeLetterRepository,
errorReporter = reporter,
)

composeRule.setContent {
val uiState = viewModel.uiState.collectAsStateWithLifecycle().value
Expand All @@ -246,7 +267,7 @@ class ReceiverRuntimeCompletionAndroidTest {
}
}

composeRule.waitUntil(timeoutMillis = 5_000) { repository.homeCallCounts.all { it == 1 } }
composeRule.waitUntil(timeoutMillis = 5_000) { homeCallCounts().all { it == 1 } }
composeRule.runOnIdle {
assertSame(ReceiverHomeUiState.Loading, viewModel.uiState.value)
}
Expand All @@ -265,7 +286,7 @@ class ReceiverRuntimeCompletionAndroidTest {
.onNodeWithText(context.getString(ReceiverR.string.receiver_home_retry))
.performClick()

composeRule.waitUntil(timeoutMillis = 5_000) { repository.homeCallCounts.all { it == 2 } }
composeRule.waitUntil(timeoutMillis = 5_000) { homeCallCounts().all { it == 2 } }
composeRule.runOnIdle {
assertSame(ReceiverHomeUiState.Loading, viewModel.uiState.value)
}
Expand All @@ -283,7 +304,7 @@ class ReceiverRuntimeCompletionAndroidTest {
),
),
mindRecords = Result.failure(IllegalStateException("mind records unavailable")),
timeLetters = Result.success(LoadCountResult(totalCount = 8)),
timeLetters = Result.success(ReceivedTimeLetterList(timeLetters = emptyList(), totalCount = 8)),
senderMessage =
Result.success(
SenderMessageInfo(
Expand All @@ -299,7 +320,12 @@ class ReceiverRuntimeCompletionAndroidTest {
.assertIsDisplayed()
composeRule.onNodeWithText("언제나 응원할게").assertIsDisplayed()
composeRule
.onNodeWithText("0개 마음의 기록이 있습니다.")
.onNodeWithText(context.getString(ReceiverR.string.receiver_home_section_count_unavailable))
.performScrollTo()
.assertIsDisplayed()
composeRule
.onNodeWithText("8개 라이프 이벤트 레터가 있습니다.")
.performScrollTo()
.assertIsDisplayed()
composeRule
.onNodeWithText("2개의 애프터노트가 있습니다.")
Expand All @@ -310,7 +336,7 @@ class ReceiverRuntimeCompletionAndroidTest {
assertEquals("receiver_home_load", reporter.failures[0].second["home_stage"])
assertEquals("receiver_home_partial_load", reporter.failures[1].second["home_stage"])
assertEquals("mind_records", reporter.failures[1].second["home_failed_sources"])
assertEquals(listOf(2, 2, 2, 2), repository.homeCallCounts)
assertEquals(listOf(2, 2, 2, 2), homeCallCounts())
}

@Test
Expand Down Expand Up @@ -624,14 +650,14 @@ class ReceiverRuntimeCompletionAndroidTest {
)
val viewModel =
ReceivedAfternoteDetailViewModel(
savedStateHandle = SavedStateHandle(mapOf("afternoteId" to "202")),
savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 202L)),
receiverRepository = repository,
errorReporter = FakeErrorReporter(),
)

composeRule.setContent {
AfternoteTheme {
ReceivedAfternoteDetailRoute(onBack = {}, viewModel = viewModel)
ReceivedAfternoteDetailRoute(onBack = {}, onNavigateToPlaylist = {}, viewModel = viewModel)
}
}

Expand Down Expand Up @@ -666,11 +692,11 @@ class ReceiverRuntimeCompletionAndroidTest {
)
val viewModel =
ReceivedAfternoteDetailViewModel(
savedStateHandle = SavedStateHandle(mapOf("afternoteId" to "303")),
savedStateHandle = SavedStateHandle(mapOf("afternoteId" to 303L)),
receiverRepository = repository,
errorReporter = FakeErrorReporter(),
)
val playlistRoutes = mutableListOf<String>()
val playlistRoutes = mutableListOf<Long>()

composeRule.setContent {
AfternoteTheme {
Expand All @@ -691,21 +717,21 @@ class ReceiverRuntimeCompletionAndroidTest {
.onNode(hasText("추억 플레이리스트") and hasClickAction())
.performClick()

assertEquals(listOf("303"), playlistRoutes)
assertEquals(listOf(303L), playlistRoutes)
assertEquals(listOf(303L), repository.detailIds)
}
}

private data class PendingHomeAttempt(
val afterNotes: CompletableDeferred<Result<AfterNotesListResult>>,
val mindRecords: CompletableDeferred<Result<LoadCountResult>>,
val timeLetters: CompletableDeferred<Result<LoadCountResult>>,
val mindRecords: CompletableDeferred<Result<ReceiverMindRecords>>,
val timeLetters: CompletableDeferred<Result<ReceivedTimeLetterList>>,
val senderMessage: CompletableDeferred<Result<SenderMessageInfo?>>,
) {
fun complete(
afterNotes: Result<AfterNotesListResult>,
mindRecords: Result<LoadCountResult>,
timeLetters: Result<LoadCountResult>,
mindRecords: Result<ReceiverMindRecords>,
timeLetters: Result<ReceivedTimeLetterList>,
senderMessage: Result<SenderMessageInfo?>,
) {
this.afterNotes.complete(afterNotes)
Expand All @@ -715,33 +741,39 @@ private data class PendingHomeAttempt(
}
}

/** 홈 한 번의 로드가 물리는 세 리포지토리 대기열에 결과 게이트를 한 벌씩 건다. */
private fun enqueueHomeAttempt(
receiverRepository: CompletionReceiverRepository,
mindRecordRepository: CompletionMindRecordReceiverRepository,
timeLetterRepository: CompletionReceiverTimeLetterRepository,
): PendingHomeAttempt {
val attempt =
PendingHomeAttempt(
afterNotes = CompletableDeferred(),
mindRecords = CompletableDeferred(),
timeLetters = CompletableDeferred(),
senderMessage = CompletableDeferred(),
)
receiverRepository.afterNoteHomeResults.addLast(attempt.afterNotes)
mindRecordRepository.homeResults.addLast(attempt.mindRecords)
timeLetterRepository.homeResults.addLast(attempt.timeLetters)
receiverRepository.senderMessageHomeResults.addLast(attempt.senderMessage)
return attempt
}

private class CompletionReceiverRepository : ReceiverRepository {
private val authCode = MutableStateFlow<String?>(null)
override val authCodeFlow: Flow<String?> = authCode

private val afterNoteHomeResults = ArrayDeque<CompletableDeferred<Result<AfterNotesListResult>>>()
private val mindRecordHomeResults = ArrayDeque<CompletableDeferred<Result<LoadCountResult>>>()
private val timeLetterHomeResults = ArrayDeque<CompletableDeferred<Result<LoadCountResult>>>()
private val senderMessageHomeResults = ArrayDeque<CompletableDeferred<Result<SenderMessageInfo?>>>()
val afterNoteHomeResults = ArrayDeque<CompletableDeferred<Result<AfterNotesListResult>>>()
val senderMessageHomeResults = ArrayDeque<CompletableDeferred<Result<SenderMessageInfo?>>>()

val detailResults = ArrayDeque<Result<ReceivedAfternoteDetail>>()
val detailIds = mutableListOf<Long>()
val homeCallCounts = mutableListOf(0, 0, 0, 0)

fun enqueueHomeAttempt(): PendingHomeAttempt {
val attempt =
PendingHomeAttempt(
afterNotes = CompletableDeferred(),
mindRecords = CompletableDeferred(),
timeLetters = CompletableDeferred(),
senderMessage = CompletableDeferred(),
)
afterNoteHomeResults.addLast(attempt.afterNotes)
mindRecordHomeResults.addLast(attempt.mindRecords)
timeLetterHomeResults.addLast(attempt.timeLetters)
senderMessageHomeResults.addLast(attempt.senderMessage)
return attempt
}
var afterNotesCalls = 0
private set
var senderMessageCalls = 0
private set

override suspend fun currentAuthCode(): String? = authCode.value

Expand All @@ -756,7 +788,7 @@ private class CompletionReceiverRepository : ReceiverRepository {
override fun getPagedReceivedAfternotes(): Flow<PagingData<AfterNoteListItem>> = flowOf(PagingData.empty())

override suspend fun getReceivedAfterNotes(): Result<AfterNotesListResult> {
homeCallCounts[0] += 1
afterNotesCalls += 1
return afterNoteHomeResults.removeFirst().await()
}

Expand All @@ -773,19 +805,36 @@ private class CompletionReceiverRepository : ReceiverRepository {
error("unexpected saveReceivedExportToFile")
}

override suspend fun loadMindRecordsCount(): Result<LoadCountResult> {
homeCallCounts[1] += 1
return mindRecordHomeResults.removeFirst().await()
override suspend fun loadSenderMessage(): Result<SenderMessageInfo?> {
senderMessageCalls += 1
return senderMessageHomeResults.removeFirst().await()
}
}

private class CompletionMindRecordReceiverRepository : MindRecordReceiverRepository {
val homeResults = ArrayDeque<CompletableDeferred<Result<ReceiverMindRecords>>>()
var getAllCalls = 0
private set

override suspend fun loadTimeLettersCount(): Result<LoadCountResult> {
homeCallCounts[2] += 1
return timeLetterHomeResults.removeFirst().await()
override suspend fun getAll(): Result<ReceiverMindRecords> {
getAllCalls += 1
return homeResults.removeFirst().await()
}
}

override suspend fun loadSenderMessage(): Result<SenderMessageInfo?> {
homeCallCounts[3] += 1
return senderMessageHomeResults.removeFirst().await()
private class CompletionReceiverTimeLetterRepository : ReceiverTimeLetterRepository {
val homeResults = ArrayDeque<CompletableDeferred<Result<ReceivedTimeLetterList>>>()
var listCalls = 0
private set

// 실패는 throw 로 전달 — 인터페이스가 Result 대신 예외 계약이라 ViewModel 쪽 runCatching 이 받는다.
override suspend fun getReceivedTimeLetters(): ReceivedTimeLetterList {
listCalls += 1
return homeResults.removeFirst().await().getOrThrow()
}

override suspend fun getReceivedTimeLetterDetail(timeLetterReceiverId: Long): ReceivedTimeLetter {
error("unexpected getReceivedTimeLetterDetail")
}
}

Expand Down
Loading
Loading