From fc04bcca2649dc6e513d9b9aa669abf9804df4e9 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 20 Oct 2026 21:08:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(home):=20=EB=82=98=EA=B0=84=20=EB=B0=A9?= =?UTF-8?q?=EC=9D=B4=20=ED=99=88=EC=97=90=20=EB=82=A8=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MyChallengeStore 에 remove 를 두고 챌린지 상세에서 탈퇴에 성공하면 뺀다 - 홈은 서버 목록 조회에 성공하면 로컬 기록을 쓰지 않는다. 조회 실패(null)일 때만 빈자리를 메운다 - 공용 FakeMyChallengeStore 를 testFixtures 에 둔다 Refs #620 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../data/repository/MyChallengeStoreImpl.kt | 5 ++ .../domain/repository/MyChallengeStore.kt | 3 ++ .../domain/fake/FakeMyChallengeStore.kt | 21 ++++++++ .../viewmodel/ChallengeDetailViewModel.kt | 3 ++ .../viewmodel/CreateChallengeViewModelTest.kt | 4 ++ .../viewmodel/ChallengeDetailJoinTest.kt | 2 + .../viewmodel/ChallengeDetailMuteTest.kt | 2 + .../viewmodel/ChallengeDetailReportTest.kt | 2 + .../viewmodel/ChallengeDetailRoomLoadTest.kt | 2 + .../viewmodel/ChallengeDetailSoloTest.kt | 54 +++++++++++++++++++ .../home/presentation/HomeChallengeUi.kt | 22 +++++--- .../presentation/viewmodel/HomeViewModel.kt | 2 +- .../presentation/HomeChallengeMergeTest.kt | 32 ++++++++--- .../viewmodel/HomeViewModelTest.kt | 29 ++++++---- 14 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 challenge/domain/src/testFixtures/kotlin/com/ruleup/challenge/domain/fake/FakeMyChallengeStore.kt diff --git a/challenge/data/src/main/kotlin/com/ruleup/challenge/data/repository/MyChallengeStoreImpl.kt b/challenge/data/src/main/kotlin/com/ruleup/challenge/data/repository/MyChallengeStoreImpl.kt index 21e38a8bc..f39e429cd 100644 --- a/challenge/data/src/main/kotlin/com/ruleup/challenge/data/repository/MyChallengeStoreImpl.kt +++ b/challenge/data/src/main/kotlin/com/ruleup/challenge/data/repository/MyChallengeStoreImpl.kt @@ -19,4 +19,9 @@ class MyChallengeStoreImpl override fun add(summary: MyChallengeSummary) { items[summary.challengeId] = summary } + + @Synchronized + override fun remove(challengeId: String) { + items.remove(challengeId) + } } diff --git a/challenge/domain/src/main/kotlin/com/ruleup/challenge/domain/repository/MyChallengeStore.kt b/challenge/domain/src/main/kotlin/com/ruleup/challenge/domain/repository/MyChallengeStore.kt index 55744e136..46b3209b7 100644 --- a/challenge/domain/src/main/kotlin/com/ruleup/challenge/domain/repository/MyChallengeStore.kt +++ b/challenge/domain/src/main/kotlin/com/ruleup/challenge/domain/repository/MyChallengeStore.kt @@ -9,4 +9,7 @@ interface MyChallengeStore { /** 생성/참여한 챌린지를 추가(같은 challengeId 면 최신값으로 갱신). */ fun add(summary: MyChallengeSummary) + + /** 나간 챌린지를 뺀다. 남겨 두면 서버 목록 조회가 실패했을 때 나간 방이 홈에 되살아난다. */ + fun remove(challengeId: String) } diff --git a/challenge/domain/src/testFixtures/kotlin/com/ruleup/challenge/domain/fake/FakeMyChallengeStore.kt b/challenge/domain/src/testFixtures/kotlin/com/ruleup/challenge/domain/fake/FakeMyChallengeStore.kt new file mode 100644 index 000000000..b134f270c --- /dev/null +++ b/challenge/domain/src/testFixtures/kotlin/com/ruleup/challenge/domain/fake/FakeMyChallengeStore.kt @@ -0,0 +1,21 @@ +package com.ruleup.challenge.domain.fake + +import com.ruleup.challenge.domain.entity.MyChallengeSummary +import com.ruleup.challenge.domain.repository.MyChallengeStore + +/** 테스트용 인메모리 [MyChallengeStore]. */ +class FakeMyChallengeStore( + initial: List = emptyList(), +) : MyChallengeStore { + private val items = initial.associateBy { it.challengeId }.toMutableMap() + + override fun all(): List = items.values.toList() + + override fun add(summary: MyChallengeSummary) { + items[summary.challengeId] = summary + } + + override fun remove(challengeId: String) { + items.remove(challengeId) + } +} diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailViewModel.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailViewModel.kt index 8476f14a7..3e1ff024e 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailViewModel.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailViewModel.kt @@ -21,6 +21,7 @@ import com.ruleup.challenge.domain.navigation.ChallengeWatchersPage import com.ruleup.challenge.domain.navigation.MyChallengesPage import com.ruleup.challenge.domain.repository.ChallengeRepository import com.ruleup.challenge.domain.repository.ExploreRepository +import com.ruleup.challenge.domain.repository.MyChallengeStore import com.ruleup.challenge.domain.repository.RoomRepository import com.ruleup.challenge.domain.repository.TargetAppStore import com.ruleup.challenge.domain.repository.WatcherRepository @@ -75,6 +76,7 @@ class ChallengeDetailViewModel private val navigationHelper: NavigationHelper, private val checkVerificationAccess: CheckVerificationAccessUseCase, private val agreeVerificationConsent: AgreeVerificationConsentUseCase, + private val myChallengeStore: MyChallengeStore, private val restrictionProvider: com.ruleup.onboarding.domain.account.AccountRestrictionProvider = com.ruleup.onboarding.domain.account .AccountRestrictionProvider { @@ -842,6 +844,7 @@ class ChallengeDetailViewModel dispatch(ChallengeDetailReducerEvent.MemberActionLoading(true)) runCatching { challengeRepository.leaveChallenge(id) } .onSuccess { result -> + myChallengeStore.remove(id) emitEffect( ChallengeDetailEffect.ShowMessage( if (result.penaltyApplied) "탈퇴했어요. 진행 이력이 있어 탈퇴 패널티가 적용됐어요" else "챌린지에서 나갔어요", diff --git a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModelTest.kt b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModelTest.kt index 0561c282b..e55ecc6b7 100644 --- a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModelTest.kt +++ b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/create/viewmodel/CreateChallengeViewModelTest.kt @@ -518,4 +518,8 @@ private class RecordingChallengeStore : MyChallengeStore { override fun add(summary: MyChallengeSummary) { added += summary } + + override fun remove(challengeId: String) { + added.removeAll { it.challengeId == challengeId } + } } diff --git a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailJoinTest.kt b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailJoinTest.kt index 47b23c9d4..77717fe75 100644 --- a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailJoinTest.kt +++ b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailJoinTest.kt @@ -17,6 +17,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig import com.ruleup.challenge.domain.entity.VerificationMethod import com.ruleup.challenge.domain.entity.VerificationType import com.ruleup.challenge.domain.fake.FakeChallengeRepository +import com.ruleup.challenge.domain.fake.FakeMyChallengeStore import com.ruleup.challenge.domain.fake.FakeWatcherRepository import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository import com.ruleup.challenge.presentation.fake.FakeAccountRepository @@ -360,6 +361,7 @@ class ChallengeDetailJoinTest { navigationHelper = nav, checkVerificationAccess = CheckVerificationAccessUseCase(permissionStatus, account), agreeVerificationConsent = AgreeVerificationConsentUseCase(account, FakeIntroRepository()), + myChallengeStore = FakeMyChallengeStore(), ) } } diff --git a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailMuteTest.kt b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailMuteTest.kt index 2a83be4aa..77a597947 100644 --- a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailMuteTest.kt +++ b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailMuteTest.kt @@ -14,6 +14,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig import com.ruleup.challenge.domain.entity.VerificationMethod import com.ruleup.challenge.domain.entity.VerificationType import com.ruleup.challenge.domain.fake.FakeChallengeRepository +import com.ruleup.challenge.domain.fake.FakeMyChallengeStore import com.ruleup.challenge.domain.fake.FakeWatcherRepository import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository import com.ruleup.challenge.presentation.fake.FakeAccountRepository @@ -240,6 +241,7 @@ class ChallengeDetailMuteTest { navigationHelper = nav, checkVerificationAccess = CheckVerificationAccessUseCase(PermissionStatusProvider { snapshot() }, FakeAccountRepository()), agreeVerificationConsent = AgreeVerificationConsentUseCase(FakeAccountRepository(), FakeIntroRepository()), + myChallengeStore = FakeMyChallengeStore(), ) } } diff --git a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailReportTest.kt b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailReportTest.kt index 523d7d6a2..91b3bd153 100644 --- a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailReportTest.kt +++ b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailReportTest.kt @@ -15,6 +15,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig import com.ruleup.challenge.domain.entity.VerificationMethod import com.ruleup.challenge.domain.entity.VerificationType import com.ruleup.challenge.domain.fake.FakeChallengeRepository +import com.ruleup.challenge.domain.fake.FakeMyChallengeStore import com.ruleup.challenge.domain.fake.FakeWatcherRepository import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository import com.ruleup.challenge.presentation.fake.FakeAccountRepository @@ -316,6 +317,7 @@ class ChallengeDetailReportTest { navigationHelper = nav, checkVerificationAccess = CheckVerificationAccessUseCase(PermissionStatusProvider { snapshot() }, FakeAccountRepository()), agreeVerificationConsent = AgreeVerificationConsentUseCase(FakeAccountRepository(), FakeIntroRepository()), + myChallengeStore = FakeMyChallengeStore(), ) } } diff --git a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailRoomLoadTest.kt b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailRoomLoadTest.kt index a9fa5f53e..2ce12ef4e 100644 --- a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailRoomLoadTest.kt +++ b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailRoomLoadTest.kt @@ -16,6 +16,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig import com.ruleup.challenge.domain.entity.VerificationMethod import com.ruleup.challenge.domain.entity.VerificationType import com.ruleup.challenge.domain.fake.FakeChallengeRepository +import com.ruleup.challenge.domain.fake.FakeMyChallengeStore import com.ruleup.challenge.domain.fake.FakeWatcherRepository import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository import com.ruleup.challenge.presentation.fake.FakeAccountRepository @@ -151,5 +152,6 @@ class ChallengeDetailRoomLoadTest { navigationHelper = RecordingNavigationHelper(), checkVerificationAccess = CheckVerificationAccessUseCase(PermissionStatusProvider { snapshot() }, FakeAccountRepository()), agreeVerificationConsent = AgreeVerificationConsentUseCase(FakeAccountRepository(), FakeIntroRepository()), + myChallengeStore = FakeMyChallengeStore(), ) } diff --git a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailSoloTest.kt b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailSoloTest.kt index 393ed54fc..bb3ca4cf9 100644 --- a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailSoloTest.kt +++ b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/viewmodel/ChallengeDetailSoloTest.kt @@ -11,11 +11,13 @@ import com.ruleup.challenge.domain.entity.ChallengeThreads import com.ruleup.challenge.domain.entity.JoinNote import com.ruleup.challenge.domain.entity.LeaveResult import com.ruleup.challenge.domain.entity.MemberRole +import com.ruleup.challenge.domain.entity.MyChallengeSummary import com.ruleup.challenge.domain.entity.OwnerType import com.ruleup.challenge.domain.entity.VerificationConfig import com.ruleup.challenge.domain.entity.VerificationMethod import com.ruleup.challenge.domain.entity.VerificationType import com.ruleup.challenge.domain.fake.FakeChallengeRepository +import com.ruleup.challenge.domain.fake.FakeMyChallengeStore import com.ruleup.challenge.domain.fake.FakeWatcherRepository import com.ruleup.challenge.domain.navigation.MyChallengesPage import com.ruleup.challenge.presentation.detail.fake.FakeReportRepository @@ -132,6 +134,56 @@ class ChallengeDetailSoloTest { assertEquals(MyChallengesPage.PATH, nav.replaced.single().path) } + @Test + fun `탈퇴하면 방금 만든 챌린지 기록에서도 빠진다`() = + runTest { + // 남아 있으면 홈 목록 조회가 실패할 때 나간 방이 다시 그려진다. + val store = FakeMyChallengeStore(listOf(summary(CHALLENGE_ID), summary("other"))) + val viewModel = + viewModel( + repo = + FakeChallengeRepository( + detail = { detail(mode = ChallengeMode.SOLO) }, + leave = { LeaveResult(penaltyApplied = false) }, + ), + store = store, + ) + viewModel.onIntent(ChallengeDetailIntent.Load(CHALLENGE_ID)) + + viewModel.onIntent(ChallengeDetailIntent.LeaveChallenge) + + assertEquals(listOf("other"), store.all().map { it.challengeId }) + } + + @Test + fun `탈퇴에 실패하면 방금 만든 챌린지 기록을 지우지 않는다`() = + runTest { + val store = FakeMyChallengeStore(listOf(summary(CHALLENGE_ID))) + val viewModel = + viewModel( + repo = + FakeChallengeRepository( + detail = { detail(mode = ChallengeMode.SOLO) }, + leave = { throw IllegalStateException("탈퇴 실패") }, + ), + store = store, + ) + viewModel.onIntent(ChallengeDetailIntent.Load(CHALLENGE_ID)) + + viewModel.onIntent(ChallengeDetailIntent.LeaveChallenge) + + assertEquals(listOf(CHALLENGE_ID), store.all().map { it.challengeId }) + } + + private fun summary(id: String) = + MyChallengeSummary( + challengeId = id, + title = "방금 만든 챌린지", + category = Category.entries.first(), + mode = ChallengeMode.SOLO, + durationDays = 30, + ) + private fun detail(mode: ChallengeMode) = ChallengeDetail( title = "매일 걷기", @@ -163,6 +215,7 @@ class ChallengeDetailSoloTest { repo: FakeChallengeRepository, rooms: FakeRoomRepository = FakeRoomRepository(), nav: RecordingNavigationHelper = RecordingNavigationHelper(), + store: FakeMyChallengeStore = FakeMyChallengeStore(), ): ChallengeDetailViewModel { val account = FakeAccountRepository(agreed = AgreementType.entries.toSet()) return ChallengeDetailViewModel( @@ -180,6 +233,7 @@ class ChallengeDetailSoloTest { navigationHelper = nav, checkVerificationAccess = CheckVerificationAccessUseCase(PermissionStatusProvider { snapshot() }, account), agreeVerificationConsent = AgreeVerificationConsentUseCase(account, FakeIntroRepository()), + myChallengeStore = store, ) } diff --git a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeChallengeUi.kt b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeChallengeUi.kt index 7bf35ae33..5e1f63050 100644 --- a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeChallengeUi.kt +++ b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/HomeChallengeUi.kt @@ -27,15 +27,18 @@ data class HomeChallengeUi( val active: Boolean, ) -/** 서버 "내 챌린지 목록"이 기준이고 진행률이 진행바·오늘 대상 여부를 채운다. */ +/** + * 서버 "내 챌린지 목록"이 기준이고 진행률이 진행바·오늘 대상 여부를 채운다. + * [myChallenges] 가 null 이면 목록 조회 실패다 — 그때만 [locals] 로 빈자리를 메운다. + */ fun mergeHomeChallenges( - myChallenges: List, + myChallenges: List?, progress: ProgressSnapshot?, locals: List, ): List { val progressById = progress?.challenges.orEmpty().associateBy { it.challengeId } - val serverCards = myChallenges.map { it.toHomeUi(progressById[it.challengeId]) } - val serverIds = myChallenges.map { it.challengeId }.toSet() + val serverCards = myChallenges.orEmpty().map { it.toHomeUi(progressById[it.challengeId]) } + val serverIds = myChallenges.orEmpty().map { it.challengeId }.toSet() // 목록 조회 실패 등으로 서버 목록이 비어도 진행률 카드는 유지한다. val progressOnlyCards = @@ -48,10 +51,15 @@ fun mergeHomeChallenges( .map { it.toHomeUi() } val coveredIds = serverIds + progressOnlyCards.map { it.challengeId }.toSet() + // 서버 목록을 받았는데 로컬에만 있으면 이미 나간 방이다. val localCards = - locals - .filter { it.challengeId !in coveredIds } - .map { it.toHomeUi() } + if (myChallenges != null) { + emptyList() + } else { + locals + .filter { it.challengeId !in coveredIds } + .map { it.toHomeUi() } + } return localCards + serverCards + progressOnlyCards } diff --git a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModel.kt b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModel.kt index 0003811aa..53d5f3385 100644 --- a/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModel.kt +++ b/home/presentation/src/main/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModel.kt @@ -255,7 +255,7 @@ class HomeViewModel val challenges = async { runCatching { challengeRepository.getMyChallenges().challenges } - .getOrDefault(emptyList()) + .getOrNull() } val progressSnapshot = async { runCatching { verificationRepository.getProgress() }.getOrNull() } diff --git a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeChallengeMergeTest.kt b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeChallengeMergeTest.kt index cf0c3a089..d6d7393dc 100644 --- a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeChallengeMergeTest.kt +++ b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/HomeChallengeMergeTest.kt @@ -20,7 +20,7 @@ class HomeChallengeMergeTest { @Test fun `서버 목록이 비어도 진행률에 있는 챌린지는 남는다`() { // 목록 조회만 실패한 경우다. - val merged = mergeHomeChallenges(emptyList(), snapshot(progress("ch1")), emptyList()) + val merged = mergeHomeChallenges(myChallenges = null, snapshot(progress("ch1")), emptyList()) assertEquals(listOf("ch1"), merged.map { it.challengeId }) } @@ -33,8 +33,8 @@ class HomeChallengeMergeTest { } @Test - fun `방금 만든 챌린지는 서버가 아직 몰라도 홈에 보인다`() { - val merged = mergeHomeChallenges(emptyList(), progress = null, locals = listOf(local("ch1"))) + fun `목록 조회가 실패하면 방금 만든 챌린지를 로컬 기록으로 메운다`() { + val merged = mergeHomeChallenges(myChallenges = null, progress = null, locals = listOf(local("ch1"))) assertEquals(listOf("ch1"), merged.map { it.challengeId }) } @@ -52,16 +52,36 @@ class HomeChallengeMergeTest { } @Test - fun `방금 만든 챌린지가 서버 목록보다 앞에 온다`() { - // 만든 직후 그것부터 보이는 게 사용자의 기대다. + fun `서버 목록을 받았으면 거기 없는 로컬 기록은 그리지 않는다`() { + // 로컬에만 남은 건 나간 방이다. 그리면 앱을 다시 켤 때까지 홈에 남는다. val merged = mergeHomeChallenges( myChallenges = listOf(myChallenge("server")), progress = null, + locals = listOf(local("left")), + ) + + assertEquals(listOf("server"), merged.map { it.challengeId }) + } + + @Test + fun `서버 목록이 비어 있어도 조회에 성공했으면 로컬 기록을 그리지 않는다`() { + val merged = mergeHomeChallenges(myChallenges = emptyList(), progress = null, locals = listOf(local("left"))) + + assertTrue(merged.isEmpty()) + } + + @Test + fun `목록 조회가 실패하면 방금 만든 챌린지가 진행률 카드보다 앞에 온다`() { + // 만든 직후 그것부터 보이는 게 사용자의 기대다. + val merged = + mergeHomeChallenges( + myChallenges = null, + progress = snapshot(progress("ongoing")), locals = listOf(local("just-made")), ) - assertEquals(listOf("just-made", "server"), merged.map { it.challengeId }) + assertEquals(listOf("just-made", "ongoing"), merged.map { it.challengeId }) } @Test diff --git a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModelTest.kt b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModelTest.kt index 7858c83ed..839250da2 100644 --- a/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModelTest.kt +++ b/home/presentation/src/test/kotlin/com/ruleup/home/presentation/viewmodel/HomeViewModelTest.kt @@ -17,7 +17,7 @@ import com.ruleup.challenge.domain.entity.VerificationConfig import com.ruleup.challenge.domain.entity.VerificationMethod import com.ruleup.challenge.domain.entity.VerificationType import com.ruleup.challenge.domain.fake.FakeChallengeRepository -import com.ruleup.challenge.domain.repository.MyChallengeStore +import com.ruleup.challenge.domain.fake.FakeMyChallengeStore import com.ruleup.domain.entity.category.Category import com.ruleup.domain.test.RecordingNavigationHelper import com.ruleup.domain.time.ServiceDate @@ -89,6 +89,24 @@ class HomeViewModelTest { ) } + @Test + fun `목록을 받았으면 나간 방의 로컬 기록은 그리지 않는다`() = + runTest { + val viewModel = + viewModel( + challenges = listOf(myChallenge("ch1")), + locals = listOf(local("left")), + ) + + viewModel.onIntent(HomeIntent.Load) + + assertEquals( + listOf("ch1"), + viewModel.uiState.value.challenges + .map { it.challengeId }, + ) + } + @Test fun `진행률 조회가 죽어도 목록은 그대로 그린다`() = runTest { @@ -474,12 +492,3 @@ class HomeViewModelTest { durationDays = 30, ) } - -/** 세션 동안만 사는 인메모리 스토어. */ -private class FakeMyChallengeStore( - private val items: List, -) : MyChallengeStore { - override fun all(): List = items - - override fun add(summary: MyChallengeSummary) = Unit -} From 57afe7b71a251d771d026e5def14c95766d24a03 Mon Sep 17 00:00:00 2001 From: Sadturtleman Date: Tue, 20 Oct 2026 21:08:13 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(challenge):=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=84=20=ED=95=9C=20=EC=A4=84=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=97=AC=20=EC=A4=80=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방 정보 탭·상세 내용 보기는 guide ?: detail ?: 「아직 입력중입니다.」 한 줄만 그린다 - 자동/직접 구분은 VerificationTypeChip 칩으로 남긴다 - 표지 일정 줄(scheduleLabel)에서 템플릿 설명을 뺀다 Closes #620 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../common/VerificationGuideText.kt | 2 +- .../detail/component/DetailCover.kt | 1 - .../detail/component/DetailInfoPage.kt | 31 ++++++++++--------- .../detail/component/RoomInfoTab.kt | 10 ++---- .../detail/ChallengeDetailContentTest.kt | 28 +++++++++++++++++ 5 files changed, 48 insertions(+), 24 deletions(-) diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/common/VerificationGuideText.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/common/VerificationGuideText.kt index 20f87b603..b9145f748 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/common/VerificationGuideText.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/common/VerificationGuideText.kt @@ -5,7 +5,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.ruleup.designsystem.theme.RuleUpTheme -/** 인증 방법 안내(서버가 준 verification.guide). 서버가 아직 채우는 중이면 그렇다고 알린다. */ +/** 인증 방법 안내(verification.guide, 없으면 템플릿 설명). 둘 다 없으면 서버가 채우는 중이라고 알린다. */ @Composable internal fun VerificationGuideText( guide: String?, diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/DetailCover.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/DetailCover.kt index e56107c92..603964bac 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/DetailCover.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/DetailCover.kt @@ -155,7 +155,6 @@ internal fun ChallengeDetail.scheduleLabel(): String = listOfNotNull( periodLabel(period.start, period.end).substringAfter(" · ", "").takeIf { it.isNotBlank() }, weeklyCount?.let { if (it >= ChallengeLimits.WEEKLY_COUNT_MAX) "매일" else "주 ${it}회" }, - verification.detail, ).joinToString(" · ") /** 방장이 나가면 봇이 자리를 지킨다(owner 가 null 이 된다). */ diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/DetailInfoPage.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/DetailInfoPage.kt index 18752afbf..1dda091dd 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/DetailInfoPage.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/DetailInfoPage.kt @@ -96,20 +96,8 @@ internal fun DetailInfoPage( } RuleUpCard { RoomSectionHeader(title = "인증 방법") - Text( - text = if (detail.verification.type.isAuto) "자동 인증" else "직접 체크", - color = RuleUpTheme.colors.brand, - style = RuleUpTheme.typography.smallBold, - modifier = - Modifier - .clip(RoundedCornerShape(99.dp)) - .background(RuleUpTheme.colors.brandSoft) - .padding(horizontal = 10.dp, vertical = 4.dp), - ) - detail.verification.detail?.let { - Text(text = it, color = RuleUpTheme.colors.textSlate, style = RuleUpTheme.typography.body) - } - VerificationGuideText(guide = detail.verification.guide) + VerificationTypeChip(isAuto = detail.verification.type.isAuto) + VerificationGuideText(guide = detail.verification.guide ?: detail.verification.detail) // 가입 전에 어떤 권한을 내줘야 하는지 알게 한다 RequiredPermissionList(tokens = requiredPermissions, snapshot = permissions) } @@ -167,6 +155,21 @@ internal fun DetailInfoPage( } } +/** 「자동 인증」·「직접 체크」 칩. */ +@Composable +internal fun VerificationTypeChip(isAuto: Boolean) { + Text( + text = if (isAuto) "자동 인증" else "직접 체크", + color = RuleUpTheme.colors.brand, + style = RuleUpTheme.typography.smallBold, + modifier = + Modifier + .clip(RoundedCornerShape(99.dp)) + .background(RuleUpTheme.colors.brandSoft) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) +} + @Composable private fun InfoRow( label: String, diff --git a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/RoomInfoTab.kt b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/RoomInfoTab.kt index bc8a15300..74fae5910 100644 --- a/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/RoomInfoTab.kt +++ b/challenge/presentation/src/main/kotlin/com/ruleup/challenge/presentation/detail/component/RoomInfoTab.kt @@ -417,14 +417,8 @@ internal fun VerificationRuleCard( ) { RuleUpCard { RoomSectionHeader(title = "인증 규칙") - Text( - text = - detail.verification.detail - ?: if (detail.verification.type.isAuto) "자동 인증" else "직접 체크", - color = RuleUpTheme.colors.textSlate, - style = RuleUpTheme.typography.body, - ) - VerificationGuideText(guide = detail.verification.guide) + VerificationTypeChip(isAuto = detail.verification.type.isAuto) + VerificationGuideText(guide = detail.verification.guide ?: detail.verification.detail) RequiredPermissionList(tokens = requiredPermissions, snapshot = permissions) } } diff --git a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/ChallengeDetailContentTest.kt b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/ChallengeDetailContentTest.kt index 883ccab07..a6bc39bb3 100644 --- a/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/ChallengeDetailContentTest.kt +++ b/challenge/presentation/src/test/kotlin/com/ruleup/challenge/presentation/detail/ChallengeDetailContentTest.kt @@ -297,6 +297,34 @@ class ChallengeDetailContentTest { compose.onNodeWithText("꺼짐").assertExists() } + @Test + fun `인증 방법은 서버 안내 한 줄만 보여 주고 템플릿 설명을 겹쳐 쓰지 않는다`() { + // 둘 다 그리면 같은 규칙이 두 문장으로 보이고, 표지 일정 줄에도 설명이 통째로 붙는다. + val base = detail("평일 아침 헬스장 출석") + val state = + loaded().copy( + detail = base.copy(verification = base.verification.copy(detail = "템플릿 설명", guide = "서버 안내")), + ) + render(state) + + compose.onNodeWithText("템플릿 설명", substring = true).assertDoesNotExist() + compose.onNodeWithText("상세 내용 보기").clickPastGuard() + + compose.onNodeWithText("서버 안내").assertExists() + compose.onNodeWithText("템플릿 설명", substring = true).assertDoesNotExist() + } + + @Test + fun `서버 안내가 없으면 템플릿 설명으로 인증 방법을 채운다`() { + val base = detail("평일 아침 헬스장 출석") + render(loaded().copy(detail = base.copy(verification = base.verification.copy(detail = "템플릿 설명", guide = null)))) + + compose.onNodeWithText("상세 내용 보기").clickPastGuard() + + compose.onNodeWithText("템플릿 설명").assertExists() + compose.onNodeWithText("아직 입력중입니다.").assertDoesNotExist() + } + @Test fun `티어가 모자라 못 들어가면 필요한 티어와 내 티어를 한글 이름으로 보여 준다`() { // enum 값을 그대로 넘기면 「SILVER 티어부터」처럼 영문이 보인다(#591).