diff --git a/app/src/main/java/com/eatssu/android/presentation/map/FestivalPartnershipPolicy.kt b/app/src/main/java/com/eatssu/android/presentation/map/FestivalPartnershipPolicy.kt new file mode 100644 index 000000000..887f908c3 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/map/FestivalPartnershipPolicy.kt @@ -0,0 +1,62 @@ +package com.eatssu.android.presentation.map + +import com.eatssu.android.domain.model.Partnership +import com.eatssu.common.enums.PeriodType +import java.time.LocalDate + +private val FESTIVAL_START_DATE: LocalDate = LocalDate.of(2026, 9, 15) +private val FESTIVAL_END_DATE: LocalDate = LocalDate.of(2026, 9, 16) + +internal fun isFestivalPartnershipPeriod(date: LocalDate): Boolean = + !date.isBefore(FESTIVAL_START_DATE) && !date.isAfter(FESTIVAL_END_DATE) + +internal fun activeFestivalPartnerships( + partnerships: List, + date: LocalDate, +): List { + if (!isFestivalPartnershipPeriod(date)) return emptyList() + + return partnerships.mapNotNull { partnership -> + val activeFestivalInfos = partnership.partnershipInfos.filter { info -> + info.periodType == PeriodType.FESTIVAL && info.isActiveOn(date) + } + + partnership + .takeIf { activeFestivalInfos.isNotEmpty() } + ?.copy(partnershipInfos = activeFestivalInfos) + } +} + +internal fun mergePartnerships( + userPartnerships: List, + festivalPartnerships: List, +): List { + val mergedByStoreName = linkedMapOf() + + (userPartnerships + festivalPartnerships).forEach { partnership -> + val previous = mergedByStoreName[partnership.storeName] + mergedByStoreName[partnership.storeName] = if (previous == null) { + partnership + } else { + previous.copy( + partnershipInfos = (previous.partnershipInfos + partnership.partnershipInfos) + .distinctBy(Partnership.PartnershipInfo::id), + naverMapUrl = previous.naverMapUrl ?: partnership.naverMapUrl, + kakaoMapUrl = previous.kakaoMapUrl ?: partnership.kakaoMapUrl, + ) + } + } + + return mergedByStoreName.values.toList() +} + +internal val Partnership.hasFestivalPartnership: Boolean + get() = partnershipInfos.any { it.periodType == PeriodType.FESTIVAL } + +private fun Partnership.PartnershipInfo.isActiveOn(date: LocalDate): Boolean { + val start = startDate.toLocalDateOrNull() ?: return false + val end = endDate.toLocalDateOrNull() ?: return false + return !date.isBefore(start) && !date.isAfter(end) +} + +private fun String.toLocalDateOrNull(): LocalDate? = runCatching(LocalDate::parse).getOrNull() diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt index d4aec5324..763be6e30 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt @@ -10,7 +10,6 @@ import android.content.Intent import android.content.pm.PackageManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement.spacedBy @@ -41,6 +40,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -50,7 +50,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.dimensionResource -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -70,9 +69,11 @@ import com.eatssu.android.presentation.MainState import com.eatssu.android.presentation.MainViewModel import com.eatssu.android.presentation.goodprice.GoodPriceMapRoute import com.eatssu.android.presentation.map.component.DepartmentBottomSheet +import com.eatssu.android.presentation.map.component.FestivalPartnershipHelp import com.eatssu.android.presentation.map.component.MapRestaurantBottomSheet import com.eatssu.android.presentation.map.component.PartnershipCategory import com.eatssu.android.presentation.map.component.PartnershipCategoryFilterRow +import com.eatssu.android.presentation.map.component.PartnershipMarkerIcon import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity import com.eatssu.android.presentation.util.TrackScreenViewEvent import com.eatssu.android.presentation.util.showToast @@ -81,7 +82,6 @@ import com.eatssu.common.UiState import com.eatssu.common.UiText import com.eatssu.common.analytics.MapAnalyticsEvent import com.eatssu.common.enums.ScreenId -import com.eatssu.common.enums.StoreType import com.eatssu.common.enums.ToastType import com.eatssu.design_system.theme.EatssuTheme import com.eatssu.design_system.theme.Gray300 @@ -423,6 +423,11 @@ internal fun MapScreen( selectedCategory: PartnershipCategory, ) { val analyticsTracker = LocalAnalyticsTracker.current + var showFestivalHelp by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(mapState.hasFestivalPartnerships) { + if (!mapState.hasFestivalPartnerships) showFestivalHelp = false + } // 학과 정보가 없을 때 보여줄 BottomSheet if (departmentSheetState.isVisible) { @@ -530,17 +535,7 @@ internal fun MapScreen( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = spacedBy(3.dp), ) { - val iconRes = when (partnership.restaurantType) { - StoreType.CAFE -> R.drawable.ic_map_marker_cafe - StoreType.PUB -> R.drawable.ic_map_marker_pub - else -> R.drawable.ic_map_marker_restaurant - } - - Image( - painter = painterResource(id = iconRes), - contentDescription = null, - modifier = Modifier.size(20.dp), - ) + PartnershipMarkerIcon(partnership = partnership) Text( text = partnership.storeName, @@ -580,6 +575,19 @@ internal fun MapScreen( }, modifier = Modifier.padding(top = 12.dp), ) + + if (mapState.hasFestivalPartnerships) { + FestivalPartnershipHelp( + isMessageVisible = showFestivalHelp, + onClick = { showFestivalHelp = !showFestivalHelp }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding( + end = 24.dp, + bottom = dimensionResource(R.dimen.bottom_nav_height) + 12.dp, + ), + ) + } } } diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt index 38233f562..2bf3e87af 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt @@ -22,8 +22,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch import timber.log.Timber +import java.time.Clock +import java.time.LocalDate import javax.inject.Inject @@ -42,6 +46,9 @@ data class MapState( val selectedCategory: PartnershipCategory = PartnershipCategory.ALL, val filterChangeResult: FilterChangeResult? = null, ) { + val hasFestivalPartnerships: Boolean + get() = partnerships.any { it.hasFestivalPartnership } + val visiblePartnerships: List get() = selectedCategory.storeType?.let { storeType -> partnerships.filter { it.restaurantType == storeType } @@ -59,6 +66,7 @@ class MapViewModel @Inject constructor( private val getPartnershipDetailUseCase: GetPartnershipDetailUseCase, private val getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase, private val analyticsTracker: AnalyticsTracker, + private val clock: Clock = Clock.systemDefaultZone(), ) : ViewModel() { private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Init) @@ -153,7 +161,27 @@ class MapViewModel @Inject constructor( _uiState.value = UiState.Loading - val partnerships = partnershipRepository.getUserCollegePartnerships() + val today = LocalDate.now(clock) + val partnerships = coroutineScope { + val userPartnerships = async { + partnershipRepository.getUserCollegePartnerships() + } + val festivalPartnerships = async { + if (isFestivalPartnershipPeriod(today)) { + activeFestivalPartnerships( + partnerships = partnershipRepository.getAllPartnerships(), + date = today, + ) + } else { + emptyList() + } + } + + mergePartnerships( + userPartnerships = userPartnerships.await(), + festivalPartnerships = festivalPartnerships.await(), + ) + } _uiState.value = UiState.Success( currentData.copy( partnerships = partnerships, diff --git a/app/src/main/java/com/eatssu/android/presentation/map/component/FestivalPartnershipHelp.kt b/app/src/main/java/com/eatssu/android/presentation/map/component/FestivalPartnershipHelp.kt new file mode 100644 index 000000000..82c31772f --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/map/component/FestivalPartnershipHelp.kt @@ -0,0 +1,97 @@ +package com.eatssu.android.presentation.map.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.eatssu.android.R +import com.eatssu.design_system.theme.EatssuTheme +import com.eatssu.design_system.theme.White +import com.eatssu.design_system.theme.pretendardMedium + +private val FestivalWine = Color(0xFF880A19) + +@Composable +internal fun FestivalPartnershipHelp( + isMessageVisible: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + horizontalArrangement = spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isMessageVisible) { + Box(modifier = Modifier.width(223.dp)) { + Image( + painter = painterResource(R.drawable.bg_festival_partnership_help), + contentDescription = null, + modifier = Modifier.size(width = 222.dp, height = 48.dp), + ) + + Column( + modifier = Modifier + .padding(start = 13.dp, top = 6.dp), + ) { + FestivalHelpText(text = stringResource(R.string.festival_partnership_help_title)) + FestivalHelpText(text = stringResource(R.string.festival_partnership_help_warning)) + } + } + } + + Box( + modifier = Modifier + .size(28.dp) + .background(FestivalWine.copy(alpha = 0.9f), CircleShape) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(R.drawable.ic_festival_partnership_info), + contentDescription = stringResource(R.string.festival_partnership_help_content_description), + modifier = Modifier.size(14.533.dp), + ) + } + } +} + +@Composable +private fun FestivalHelpText(text: String) { + Text( + text = text, + color = White, + maxLines = 1, + style = EatssuTheme.typography.caption2.copy( + fontFamily = pretendardMedium, + ), + ) +} + +@Preview(showBackground = true) +@Composable +private fun FestivalPartnershipHelpPreview() { + EatssuTheme { + FestivalPartnershipHelp( + isMessageVisible = true, + onClick = {}, + modifier = Modifier.padding(16.dp), + ) + } +} diff --git a/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipMarkerIcon.kt b/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipMarkerIcon.kt new file mode 100644 index 000000000..9ce3f2d34 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/presentation/map/component/PartnershipMarkerIcon.kt @@ -0,0 +1,53 @@ +package com.eatssu.android.presentation.map.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.eatssu.android.R +import com.eatssu.android.domain.model.Partnership +import com.eatssu.android.presentation.map.hasFestivalPartnership +import com.eatssu.android.presentation.map.iconRes +import com.eatssu.common.enums.StoreType + +private val FestivalWine = Color(0xFF880A19) + +@Composable +internal fun PartnershipMarkerIcon( + partnership: Partnership, + modifier: Modifier = Modifier, +) { + if (partnership.hasFestivalPartnership) { + Box( + modifier = modifier + .size(20.dp) + .background(FestivalWine, CircleShape), + contentAlignment = Alignment.Center, + ) { + Image( + painter = painterResource(id = partnership.restaurantType.iconRes), + contentDescription = null, + modifier = Modifier.size(14.dp), + ) + } + } else { + val iconRes = when (partnership.restaurantType) { + StoreType.CAFE -> R.drawable.ic_map_marker_cafe + StoreType.PUB -> R.drawable.ic_map_marker_pub + else -> R.drawable.ic_map_marker_restaurant + } + + Image( + painter = painterResource(id = iconRes), + contentDescription = null, + modifier = modifier.size(20.dp), + ) + } +} diff --git a/app/src/main/res/drawable/bg_festival_partnership_help.xml b/app/src/main/res/drawable/bg_festival_partnership_help.xml new file mode 100644 index 000000000..ad9ac8378 --- /dev/null +++ b/app/src/main/res/drawable/bg_festival_partnership_help.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_festival_partnership_info.xml b/app/src/main/res/drawable/ic_festival_partnership_info.xml new file mode 100644 index 000000000..a86611198 --- /dev/null +++ b/app/src/main/res/drawable/ic_festival_partnership_info.xml @@ -0,0 +1,27 @@ + + + + + diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index fc4f0741f..6f2c9f794 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -32,6 +32,9 @@ Good Price Stores Map Force Update Campus Partners + See 2026 Festival partner stores! + Cannot combine with regular benefits. + Festival partnership information Good Price diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 5402026c4..5a3e74016 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -32,6 +32,9 @@ お手頃価格のお店マップ 強制アップデート 学校提携 + 2026年サークル連合祭の提携店舗をチェック! + 既存の提携特典とは併用できません。 + 学園祭提携情報 お手頃価格 diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 3fca87140..13838cd74 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -32,6 +32,9 @@ Bản đồ cửa hàng giá tốt Bắt buộc cập nhật Đối tác trường + Xem ưu đãi Lễ hội CLB 2026! + Không dùng chung ưu đãi hiện có. + Thông tin đối tác lễ hội Giá tốt diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75894062e..9f3632c7d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,6 +29,9 @@ 착한가격업소 지도 강제 업데이트 학교 제휴 + 2026 동연제 제휴 매장을 확인해보세요! + 기존 제휴와 중복 적용되지 않아요. + 축제 제휴 안내 착한 가격 diff --git a/app/src/test/java/com/eatssu/android/presentation/map/FestivalPartnershipPolicySpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/FestivalPartnershipPolicySpec.kt new file mode 100644 index 000000000..e4d05cc62 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/presentation/map/FestivalPartnershipPolicySpec.kt @@ -0,0 +1,94 @@ +package com.eatssu.android.presentation.map + +import com.eatssu.android.domain.model.Partnership +import com.eatssu.android.test.AppBehaviorSpec +import com.eatssu.android.test.samplePartnership +import com.eatssu.common.enums.PeriodType +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import java.time.LocalDate + +class FestivalPartnershipPolicySpec : AppBehaviorSpec({ + + given("2026 동연제 제휴 노출 정책") { + val festivalInfo = Partnership.PartnershipInfo( + id = 100, + partnershipType = "DISCOUNT", + collegeName = "축제", + departmentName = "", + likeCount = 0, + isLiked = false, + description = "축제 할인", + startDate = "2026-09-15", + endDate = "2026-09-16", + periodType = PeriodType.FESTIVAL, + ) + val normalInfo = festivalInfo.copy( + id = 101, + description = "일반 할인", + periodType = PeriodType.NORMAL, + ) + val source = samplePartnership( + storeName = "축제 매장", + infos = listOf(normalInfo, festivalInfo), + ) + + `when`("행사 시작 전이거나 종료 후이면") { + then("축제 제휴를 노출하지 않는다") { + activeFestivalPartnerships( + partnerships = listOf(source), + date = LocalDate.of(2026, 9, 14), + ) shouldBe emptyList() + activeFestivalPartnerships( + partnerships = listOf(source), + date = LocalDate.of(2026, 9, 17), + ) shouldBe emptyList() + } + } + + `when`("9월 15일 또는 16일이면") { + then("FESTIVAL 타입인 제휴만 노출한다") { + listOf( + LocalDate.of(2026, 9, 15), + LocalDate.of(2026, 9, 16), + ).forEach { date -> + val result = activeFestivalPartnerships( + partnerships = listOf(source), + date = date, + ) + + result.single().partnershipInfos shouldContainExactly listOf(festivalInfo) + result.single().hasFestivalPartnership shouldBe true + } + } + } + + `when`("서버의 축제 날짜 형식이 잘못되면") { + then("앱을 종료하지 않고 해당 제휴만 제외한다") { + val invalid = source.copy( + partnershipInfos = listOf(festivalInfo.copy(startDate = "invalid")), + ) + + activeFestivalPartnerships( + partnerships = listOf(invalid), + date = LocalDate.of(2026, 9, 15), + ) shouldBe emptyList() + } + } + + `when`("동일 매장에 학과 제휴와 축제 제휴가 함께 있으면") { + then("마커는 하나로 유지하고 상세 제휴 목록을 합친다") { + val normal = source.copy(partnershipInfos = listOf(normalInfo)) + val festival = source.copy(partnershipInfos = listOf(festivalInfo)) + + val result = mergePartnerships( + userPartnerships = listOf(normal), + festivalPartnerships = listOf(festival), + ) + + result.size shouldBe 1 + result.single().partnershipInfos shouldContainExactly listOf(normalInfo, festivalInfo) + } + } + } +}) diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt index b3b2ab3b0..fab5daed6 100644 --- a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt @@ -25,6 +25,9 @@ import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.runTest +import java.time.Clock +import java.time.Instant +import java.time.ZoneId import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) @@ -72,13 +75,13 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ val festivalInfo = Partnership.PartnershipInfo( id = 1, partnershipType = "DISCOUNT", - collegeName = "IT", - departmentName = "CS", + collegeName = "축제", + departmentName = "", likeCount = 1, isLiked = false, description = "축제 할인", - startDate = "2025-05-01", - endDate = "2025-05-03", + startDate = "2026-09-15", + endDate = "2026-09-16", periodType = PeriodType.FESTIVAL, ) val normalInfo = Partnership.PartnershipInfo( @@ -89,14 +92,20 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ likeCount = 1, isLiked = false, description = "상시 할인", - startDate = "2025-01-01", - endDate = "2025-12-31", + startDate = "2026-01-01", + endDate = "2026-12-31", periodType = PeriodType.NORMAL, ) - val allPartnerships = listOf( + val festivalPartnerships = listOf( samplePartnership( storeName = "Festival Cafe", - infos = listOf(festivalInfo, normalInfo), + infos = listOf(festivalInfo), + ) + ) + val userPartnerships = listOf( + samplePartnership( + storeName = "Festival Cafe", + infos = listOf(normalInfo), ) ) coEvery { @@ -106,14 +115,18 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ college = College(collegeId = 1, collegeName = "IT"), department = Department(departmentId = 11, departmentName = "컴퓨터학부"), ) - coEvery { partnershipRepository.getAllPartnerships() } returns allPartnerships - coEvery { partnershipRepository.getUserCollegePartnerships() } returns allPartnerships + coEvery { partnershipRepository.getAllPartnerships() } returns festivalPartnerships + coEvery { partnershipRepository.getUserCollegePartnerships() } returns userPartnerships val viewModel = MapViewModel( partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, analyticsTracker = analyticsTracker, + clock = Clock.fixed( + Instant.parse("2026-09-15T03:00:00Z"), + ZoneId.of("Asia/Seoul"), + ), ) then("별도 Festival 필터 없이 전체 카테고리에 포함한다") { @@ -122,10 +135,47 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ val state = viewModel.uiState.value as UiState.Success state.data.selectedCategory shouldBe PartnershipCategory.ALL state.data.partnerships.first().partnershipInfos shouldBe listOf( - festivalInfo, normalInfo, + festivalInfo, ) + state.data.hasFestivalPartnerships shouldBe true + } + coVerify(exactly = 1) { partnershipRepository.getAllPartnerships() } + coVerify(exactly = 1) { partnershipRepository.getUserCollegePartnerships() } + } + } + } + + `when`("동연제 기간이 아니면") { + val userPartnerships = listOf(samplePartnership(storeName = "Regular Cafe")) + coEvery { + getUserCollegeDepartmentUseCase() + } returns sampleUserInfo( + nickname = "eatssu", + college = College(collegeId = 1, collegeName = "IT"), + department = Department(departmentId = 11, departmentName = "컴퓨터학부"), + ) + coEvery { partnershipRepository.getUserCollegePartnerships() } returns userPartnerships + + val viewModel = MapViewModel( + partnershipRepository = partnershipRepository, + getPartnershipDetailUseCase = getPartnershipDetailUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, + clock = Clock.fixed( + Instant.parse("2026-09-17T03:00:00Z"), + ZoneId.of("Asia/Seoul"), + ), + ) + + then("기존 제휴만 로드하고 전체 제휴 API는 호출하지 않는다") { + runTest { + eventually(2.seconds) { + val state = viewModel.uiState.value as UiState.Success + state.data.partnerships shouldBe userPartnerships + state.data.hasFestivalPartnerships shouldBe false } + coVerify(exactly = 0) { partnershipRepository.getAllPartnerships() } } } }