From cdc7f7fcfb37925c76b9a2d48f6cfe3711e0fe50 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 4 Sep 2026 14:43:44 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(home):=20=ED=95=A9=EB=B3=B8=20User?= =?UTF-8?q?Repository=20=EB=8C=80=EC=8B=A0=20=EC=A2=81=EC=9D=80=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EB=91=98=EC=9D=84=20=EC=A3=BC=EC=9E=85?= =?UTF-8?q?=ED=95=9C=EB=8B=A4=20(#1742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetHomeSummaryUseCase 는 getMyProfile() 과 getReceivers() 둘만 쓴다. 각각의 계약으로 갈라 주입한다 — 이 소비자가 합본의 나머지 표면(탈퇴·푸시설정 등)에 닿을 수 없게 된다. HomeWeeklyCountBudgetTest 의 Proxy 스텁도 둘로 갈랐다. 주석이 «UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다» 였는데, 이제 프록시가 무는 표면 자체가 그 시나리오 크기다. 각 스텁이 자기 호출 하나만 답하고 나머지는 error 로 떨어진다. HomeTabViewModelTest 는 같은 FakeUserRepository 를 두 인자에 넘긴다. UserRepository 가 두 좁은 계약을 상속하므로 그 테스트가 쥔 완료 시점 제어가 그대로다. 동작 변경 0. Hilt 는 이미 두 계약을 같은 인스턴스로 바인딩하고 있다 (CoreUserRepositoryModule:43·46). :app:compileDebugKotlin 으로 그래프를 확인했다. Co-Authored-By: Claude Opus 5 (1M context) --- .../usecase/GetHomeSummaryUseCase.kt | 10 ++++--- .../home/presentation/HomeTabViewModelTest.kt | 5 +++- .../presentation/HomeWeeklyCountBudgetTest.kt | 27 ++++++++++++++----- 3 files changed, 30 insertions(+), 12 deletions(-) diff --git a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt index 9e871ad575..0cb8ecbb48 100644 --- a/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt +++ b/feature/home/presentation/src/main/kotlin/com/afternote/feature/home/presentation/usecase/GetHomeSummaryUseCase.kt @@ -1,7 +1,8 @@ package com.afternote.feature.home.presentation.usecase import com.afternote.core.common.result.runCatchingCancellable -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.feature.home.presentation.usecase.HomeSummary import com.afternote.feature.mindrecord.domain.repository.DailyQuestionRepository import com.afternote.feature.mindrecord.domain.repository.DiaryRepository @@ -27,15 +28,16 @@ import javax.inject.Inject class GetHomeSummaryUseCase @Inject constructor( - private val userRepository: UserRepository, + private val myProfileRepository: MyProfileRepository, + private val userReceiverRepository: UserReceiverRepository, private val dailyQuestionRepository: DailyQuestionRepository, private val getWeeklyRecordCount: GetWeeklyRecordCountUseCase, ) { suspend operator fun invoke(): Result = runCatchingCancellable { coroutineScope { - val profileDeferred = async { userRepository.getMyProfile() } - val receiversDeferred = async { userRepository.getReceivers() } + val profileDeferred = async { myProfileRepository.getMyProfile() } + val receiversDeferred = async { userReceiverRepository.getReceivers() } val todayQuestionDeferred = async { dailyQuestionRepository.getToday() } // 예산을 **async 안에서** 건다. 밖에서 걸면 `await()` 만 끊기고 이 async 는 // 여전히 coroutineScope 의 자식이라, scope 가 그 자식을 끝까지 기다린다 — 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 769ce04f22..41b4cfe985 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 @@ -249,7 +249,10 @@ private class Fixture { HomeTabViewModel( getHomeSummary = GetHomeSummaryUseCase( - userRepository = server.userRepository, + // 같은 페이크가 두 좁은 계약을 다 구현한다 — UserRepository 가 둘을 + // 상속하므로, 이 테스트가 쥔 완료 시점 제어가 그대로 유지된다 (#1742). + myProfileRepository = server.userRepository, + userReceiverRepository = server.userRepository, dailyQuestionRepository = server.dailyQuestionRepository, // 주간 기록 수는 이 테스트의 관심사가 아니다 — 실패로 고정해 보조 호출 // 실패가 홈 전체를 깨뜨리지 않는다는 계약도 함께 태운다 (#562). diff --git a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt index 09585c6389..9e0a54bbc6 100644 --- a/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt +++ b/feature/home/presentation/src/test/kotlin/com/afternote/feature/home/presentation/HomeWeeklyCountBudgetTest.kt @@ -1,6 +1,7 @@ package com.afternote.feature.home.presentation -import com.afternote.core.domain.repository.UserRepository +import com.afternote.core.domain.repository.MyProfileRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.model.user.Receiver import com.afternote.core.model.user.User import com.afternote.feature.home.presentation.usecase.GetHomeSummaryUseCase @@ -58,7 +59,8 @@ class HomeWeeklyCountBudgetTest { private fun useCase(weeklyDelayMillis: Long): GetHomeSummaryUseCase = GetHomeSummaryUseCase( - userRepository = StubUserRepository, + myProfileRepository = StubMyProfileRepository, + userReceiverRepository = StubUserReceiverRepository, dailyQuestionRepository = StubDailyQuestionRepository, getWeeklyRecordCount = GetWeeklyRecordCountUseCase(SlowWeeklyReportRepository(weeklyDelayMillis)), ) @@ -83,18 +85,29 @@ private class SlowWeeklyReportRepository( } } -/** UserRepository 는 표면이 넓다 — 이 시나리오가 타는 호출만 답한다. */ -private val StubUserRepository: UserRepository = +// 두 계약 다 이 시나리오가 타는 호출 하나씩만 답한다. 프록시가 무는 표면이 곧 계약 크기라, +// 다른 멤버가 호출되면 `error` 로 떨어진다 (#1741·#1742). +private val StubMyProfileRepository: MyProfileRepository = Proxy.newProxyInstance( - UserRepository::class.java.classLoader, - arrayOf(UserRepository::class.java), + MyProfileRepository::class.java.classLoader, + arrayOf(MyProfileRepository::class.java), ) { _, method, _ -> when (method.name) { "getMyProfile" -> User(name = "효기", email = "user@example.com", phone = null, profileImageUrl = null) + else -> error("Unexpected call: ${method.name}") + } + } as MyProfileRepository + +private val StubUserReceiverRepository: UserReceiverRepository = + Proxy.newProxyInstance( + UserReceiverRepository::class.java.classLoader, + arrayOf(UserReceiverRepository::class.java), + ) { _, method, _ -> + when (method.name) { "getReceivers" -> emptyList() else -> error("Unexpected call: ${method.name}") } - } as UserRepository + } as UserReceiverRepository private object StubDailyQuestionRepository : DailyQuestionRepository { override suspend fun getList( From 52a26975a225b8233fbc0d06a68cbb40da44f1e2 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 4 Sep 2026 15:47:03 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(app):=20=EC=A2=81=ED=9E=8C=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EC=A3=BC=EC=9E=85=EC=97=90=20app=20androidTest=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=EB=B6=80=EB=A5=BC=20=EB=A7=9E=EC=B6=98?= =?UTF-8?q?=EB=8B=A4=20(#1742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GetHomeSummaryUseCase` 가 `userRepository` 하나 대신 `myProfileRepository`· `userReceiverRepository` 둘을 받게 되면서 `HomeWeeklyCountAndroidTest` 의 생성부가 컴파일되지 않아 `:app:compileDebugAndroidTestKotlin` 이 죽었다. 단위 테스트와 같은 방식으로 맞춘다 — `UserRepository` 가 두 좁은 계약을 상속하므로 페이크 한 인스턴스를 둘 다에 넘긴다. 페이크 동작과 단언은 그대로다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ea5nJ6nL7c2pdCNzKGztW2 --- .../afternote_fe/HomeWeeklyCountAndroidTest.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt index ee52495b1a..85880a8a48 100644 --- a/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt +++ b/app/src/androidTest/java/com/afternote/afternote_fe/HomeWeeklyCountAndroidTest.kt @@ -161,11 +161,14 @@ class HomeWeeklyCountAndroidTest { dailyQuestionAmount: Int = 0, diaryAmount: Int = 0, weeklyFailure: Throwable? = null, - ): HomeTabViewModel = - HomeTabViewModel( + ): HomeTabViewModel { + // 같은 페이크가 두 좁은 계약을 다 구현한다 — UserRepository 가 둘을 상속한다 (#1742). + val userRepository = appTestUserRepository() + return HomeTabViewModel( getHomeSummary = GetHomeSummaryUseCase( - userRepository = appTestUserRepository(), + myProfileRepository = userRepository, + userReceiverRepository = userRepository, dailyQuestionRepository = FakeDailyQuestionRepository(), getWeeklyRecordCount = GetWeeklyRecordCountUseCase( @@ -175,6 +178,7 @@ class HomeWeeklyCountAndroidTest { userProfileCacheRepository = FakeUserProfileCacheRepository(), errorReporter = SilentErrorReporter, ) + } private companion object { const val TIMEOUT = 5_000L From e72cc153ae3a7d4ade672e4f05ada75ca1d5d742 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Fri, 4 Sep 2026 16:19:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(app):=20=EA=B3=84=EC=B8=A1=20Hilt=20?= =?UTF-8?q?=EA=B7=B8=EB=9E=98=ED=94=84=EC=97=90=20=EC=A2=81=EC=9D=80=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=202=EC=A2=85=20=EB=B0=94=EC=9D=B8=EB=94=A9?= =?UTF-8?q?=EC=9D=84=20=EC=84=B8=EC=9A=B4=EB=8B=A4=20(#1742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Pixel 2 API 30 androidTest` 가 두 번 연속 red 였다. 에뮬레이터가 아니라 `:app:hiltJavaCompileDebugAndroidTest` 가 죽는다. [Dagger/MissingBinding] MyProfileRepository cannot be provided without an @Provides-annotated method. MyProfileRepository is injected at GetHomeSummaryUseCase(myProfileRepository, …) `TestCoreUserRepositoryModule` 이 `CoreUserRepositoryModule` 을 통째로 `replaces` 하는데, 좁은 계약 2종의 `@Binds` 가 바로 그 정본 모듈에 있었다. `GetHomeSummaryUseCase` 가 `UserRepository` 대신 둘을 받게 되면서 계측 그래프에서만 비게 됐다 — 단위 테스트는 Hilt 를 타지 않아 드러나지 않았다. **#1837 이 애프터노트 몫으로 같은 자리에 같은 블록을 이미 넣어 뒀다.** 그래서 새로 쓰지 않고 그 파일을 그대로 가져왔다 — 두 브랜치의 해당 파일이 바이트 단위로 같아, 어느 쪽이 먼저 들어가도 충돌 없이 합쳐진다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Ea5nJ6nL7c2pdCNzKGztW2 --- .../afternote/afternote_fe/test/TestHiltModules.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 80ad179e49..986e3f4fdf 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 @@ -5,7 +5,9 @@ import com.afternote.afternote_fe.notification.di.NotificationPermissionStoreMod import com.afternote.afternote_fe.reporting.ErrorReportingModule import com.afternote.core.common.reporting.ErrorReporter import com.afternote.core.data.di.CoreUserRepositoryModule +import com.afternote.core.domain.repository.MyProfileRepository import com.afternote.core.domain.repository.UserProfileCacheRepository +import com.afternote.core.domain.repository.UserReceiverRepository import com.afternote.core.domain.repository.UserRepository import com.afternote.core.domain.repository.auth.AuthRepository import com.afternote.core.domain.testing.FakeUserProfileCacheRepository @@ -46,6 +48,16 @@ object TestCoreUserRepositoryModule { @Singleton fun provideUserRepository(): UserRepository = appTestUserRepository() + // 책임별 좁은 계약 2종 (#1282). 이 모듈이 `CoreUserRepositoryModule` 을 통째로 replaces 하므로 + // 거기 있던 두 바인딩도 같이 사라진다 — 좁은 계약을 주입받는 화면(#1743 의 애프터노트 상세·에디터)이 + // 계측 그래프에서 MissingBinding 으로 깨지지 않게 여기서 다시 잇는다. + // 프로덕션과 같은 모양으로 싱글턴 `UserRepository` 를 경유해, 계측이 세운 fake 한 인스턴스를 공유한다. + @Provides + fun provideUserReceiverRepository(userRepository: UserRepository): UserReceiverRepository = userRepository + + @Provides + fun provideMyProfileRepository(userRepository: UserRepository): MyProfileRepository = userRepository + @Provides @Singleton fun provideUserProfileCacheRepository(): UserProfileCacheRepository = FakeUserProfileCacheRepository()