Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8fa448b
test: kotest mockk turbine 설정과 초기 ViewModel BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
a9d92cb
test: info/mypage 테스트를 보강하고 비동기 테스트 인프라를 안정화했어요
PeraSite Feb 14, 2026
7edc985
test: intro main map userinfo ViewModel BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
1669cb6
test: 핵심 분기 UseCase BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
f1f5235
test: ApiResult 유틸과 공통 계약 BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
266b116
test: 네트워크 래퍼와 PagingSource BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
4e32551
test: oauth와 user repository BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
ed3ef1f
test: meal menu partnership repository BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
bab627c
test: 나머지 remote repository BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
fe48d51
test: 나머지 domain usecase BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
b9580e6
test: DTO response mapper BDD 테스트를 추가했어요
PeraSite Feb 14, 2026
4bef13c
test: alarm usecase 스펙을 전체 스위트 기준으로 안정화했어요
PeraSite Feb 14, 2026
b66d890
feat: Clock의 의존성 주입을 추가했어요
PeraSite Feb 14, 2026
5302739
test: 네트워크 어댑터와 로그인 유틸 테스트를 추가했어요
PeraSite Feb 14, 2026
49dbc74
test: 누락 분기 테스트와 공용 DSL을 보강했어요
PeraSite Feb 14, 2026
9058c93
test: 유저 정보 저장 분기 조건 테스트를 보강했어요
PeraSite Feb 14, 2026
d82c9ec
fix: reflect gemini review feedback in review and user info flows
PeraSite Feb 14, 2026
3ad6314
fix: 고정 메뉴 리뷰에서 빈 likeMenuIdList를 null로 처리했어요
PeraSite Feb 14, 2026
2486cb6
fix: 고정 메뉴 리뷰에서 빈 likeMenuIdList를 null로 처리했어요
PeraSite Feb 14, 2026
cf6ac98
fix: 닉네임은 원격 변경 성공 후에만 로컬 저장하도록 수정했어요
PeraSite Feb 14, 2026
2ccd914
ci: debug workflow에서 unit test 통과를 필수화했어요
PeraSite Feb 14, 2026
3ec3b72
Merge branch 'develop' into feat/all-logic-test
PeraSite Mar 4, 2026
b34d630
fix: 기존 develop 브랜치 수정으로 인한 테스트 오류 해결
PeraSite Mar 4, 2026
4e4cd3c
fix: CalendarUtil 의 convertMillisToDateString 이 Timezone을 반영하지 않는 문제 수정
PeraSite Mar 4, 2026
f5c5cc2
feat: ApiResultCall 에서 code, message가 존재하지 않을 때 처리 개선 및 테스트 수정
PeraSite Mar 4, 2026
8812d9a
chore: null일 시 TODO 제거
PeraSite Mar 4, 2026
557ee6c
chore: deprecated된 함수 사용 제거
PeraSite Mar 4, 2026
bdcbb32
fix: MainViewModel 초기화를 하나의 Coroutine Scope에서 실행
PeraSite Mar 4, 2026
7eabcb3
fix: CalendarUtil 주간 계산 안정성 개선, CafeteriaFragment 사용 코드 정리
PeraSite Mar 4, 2026
c388e55
test: MenuViewModel 테스트 추가
PeraSite Mar 4, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/debug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ jobs:
- name: Grant execute permission for gradlew
run: chmod +x gradlew

- name: Run unit tests (required)
run: ./gradlew :app:testDebugUnitTest :core:common:testDebugUnitTest

# - name: Assemble Debug APK
# if: >
# github.event_name == 'pull_request' &&
Expand Down
12 changes: 12 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,12 @@ android {
lint {
abortOnError = false
}

testOptions {
unitTests.all {
it.useJUnitPlatform()
}
}
}

dependencies {
Expand Down Expand Up @@ -181,6 +187,12 @@ dependencies {

// Testing libraries
testImplementation(libs.junit)
testImplementation(libs.kotest.runner.junit5)
testImplementation(libs.kotest.assertions.core)
testImplementation(libs.kotest.property)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.turbine)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.espresso.core)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ class ReviewRepositoryImpl @Inject constructor(private val reviewService: Review
rating = rating,
content = content,
imageUrls = imageUrls,
menuLike = likeMenuIdList?.let {
menuLike = likeMenuIdList?.firstOrNull()?.let {
WriteMenuReviewRequest.MenuLike(
menuId = it.first(),
menuId = it,
isLike = true,
)
}
Expand Down
5 changes: 5 additions & 0 deletions app/src/main/java/com/eatssu/android/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.time.Clock
import javax.inject.Singleton

@Module
Expand All @@ -17,4 +18,8 @@ object AppModule {
fun provideContext(application: Application): Context {
return application.applicationContext
}

@Provides
@Singleton
fun provideClock(): Clock = Clock.systemDefaultZone()
}
20 changes: 11 additions & 9 deletions app/src/main/java/com/eatssu/android/di/network/ApiResultCall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,19 @@ class ApiResultCall<T : Any>(
)

// BaseResponse 형태인지 확인 (isSuccess가 false이고 code와 message가 있는 경우)
if (errorResponse.isSuccess == false &&
errorResponse.code != null &&
errorResponse.message != null
) {
if (errorResponse.isSuccess == false) {
val parsedCode = errorResponse.code
val parsedMessage = errorResponse.message

Timber.d("ApiResultCall - Parsed error response: ${errorResponse.code} - ${errorResponse.message}")
if (parsedCode != null && !parsedMessage.isNullOrBlank()) {

return ApiResult.Failure(
errorResponse.code!!,
errorResponse.message
)
Timber.d("ApiResultCall - Parsed error response: $parsedCode - $parsedMessage")

return ApiResult.Failure(
parsedCode,
parsedMessage
)
}
}
} catch (e: Exception) {
Timber.w(e, "ApiResultCall - Failed to parse errorBody as BaseResponse")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@ import android.content.Intent
import com.eatssu.android.alarm.NotificationReceiver
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Calendar
import java.time.Clock
import javax.inject.Inject

class AlarmUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val clock: Clock,
) {

fun scheduleAlarm() {
Expand All @@ -21,13 +23,14 @@ class AlarmUseCase @Inject constructor(
)

val calendar = Calendar.getInstance().apply {
timeInMillis = clock.millis()
set(Calendar.HOUR_OF_DAY, 11)
set(Calendar.MINUTE, 0)
set(Calendar.SECOND, 0)
set(Calendar.MILLISECOND, 0)
}

if (calendar.timeInMillis <= System.currentTimeMillis()) {
if (calendar.timeInMillis <= clock.millis()) {
calendar.add(Calendar.DAY_OF_YEAR, 1)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ class SetUserNicknameUseCase @Inject constructor(
private val accountDataStore: AccountDataStore
) {
suspend operator fun invoke(nickname: String): Result<Unit> {
// 로컬 저장
accountDataStore.setName(nickname)
return userRepository.updateUserName(ChangeNicknameRequest(nickname))
val result = userRepository.updateUserName(ChangeNicknameRequest(nickname))
if (result.isSuccess) {
// 서버 닉네임 변경이 성공한 경우에만 로컬 닉네임 변경
accountDataStore.setName(nickname)
}
return result
}
}
}
Comment on lines +25 to +32
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋습니다👍

82 changes: 35 additions & 47 deletions app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@ class MainViewModel @Inject constructor(
val uiEvent: SharedFlow<UiEvent> = _uiEvent

init {
loadStoredUserDepartment()
getUserDepartment()
fetchAndCheckNickname()
viewModelScope.launch {
loadStoredUserDepartment()
getUserDepartment()
fetchAndCheckNickname()
}
}

fun refreshUserDepartment() {
Expand All @@ -57,21 +59,12 @@ class MainViewModel @Inject constructor(
}
}

private fun fetchAndCheckNickname() {
viewModelScope.launch {
_uiState.value = UiState.Loading

val nickname = getUserNickNameUseCase()

// 1) 닉네임 없음
if (nickname.isBlank()) {
_uiState.value = UiState.Success(MainState.NicknameNull)
_uiEvent.emit(UiEvent.ShowToast(UiText.StringResource(R.string.set_nickname), ToastType.ERROR))
return@launch
}
private suspend fun fetchAndCheckNickname() {
val nickname = getUserNickNameUseCase()

// 2) 정상 닉네임
_uiState.value = UiState.Success(MainState.NicknameExists(nickname))
if (nickname.isBlank()) {
_uiState.value = UiState.Success(MainState.NicknameNull)
_uiEvent.emit(UiEvent.ShowToast(UiText.StringResource(R.string.set_nickname), ToastType.ERROR))
}
}

Expand Down Expand Up @@ -99,49 +92,44 @@ class MainViewModel @Inject constructor(
return data
}

private fun loadStoredUserDepartment() {
viewModelScope.launch {
val userInfo = getUserCollegeDepartmentUseCase()
_uiState.value = UiState.Success(
MainState.DepartmentState(
departmentName = userInfo.userDepartment.departmentName,
showUserDepartmentBottomSheet =
(userInfo.userCollege.collegeId == -1 || userInfo.userDepartment.departmentId == -1)
)
private suspend fun loadStoredUserDepartment() {
val userInfo = getUserCollegeDepartmentUseCase()
_uiState.value = UiState.Success(
MainState.DepartmentState(
departmentName = userInfo.userDepartment.departmentName,
showUserDepartmentBottomSheet =
(userInfo.userCollege.collegeId == -1 || userInfo.userDepartment.departmentId == -1)
)
}
)
}

private fun getUserDepartment() {
viewModelScope.launch {
val (college, department) = userRepository.getUserCollegeDepartment() ?: run {
_uiState.value = UiState.Error
_uiEvent.emit(
UiEvent.ShowToast(
UiText.StringResource(R.string.not_found),
ToastType.ERROR
)
private suspend fun getUserDepartment() {
val (college, department) = userRepository.getUserCollegeDepartment() ?: run {
_uiEvent.emit(
UiEvent.ShowToast(
UiText.StringResource(R.string.not_found),
ToastType.ERROR
)
return@launch
}
)
return
}

setUserCollegeDepartmentUseCase(college, department)
setUserCollegeDepartmentUseCase(college, department)

_uiState.value = UiState.Success(
MainState.DepartmentState(
departmentName = department.departmentName,
showUserDepartmentBottomSheet =
(college.collegeId == -1 || department.departmentId == -1)
)
_uiState.value = UiState.Success(
MainState.DepartmentState(
departmentName = department.departmentName,
showUserDepartmentBottomSheet =
(college.collegeId == -1 || department.departmentId == -1)
)
}
)
}

}


sealed class MainState {
object NicknameNull : MainState()
data class NicknameExists(val nickname: String) : MainState()
object LoggedOut : MainState()
data class DepartmentState(
val departmentName: String? = "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import com.eatssu.android.databinding.FragmentCafeteriaBinding
import com.eatssu.android.presentation.MainViewModel
import com.eatssu.android.presentation.base.BaseFragment
import com.eatssu.android.presentation.cafeteria.calendar.CalendarAdapter
import com.eatssu.android.presentation.cafeteria.calendar.CalendarAdapter.OnItemListener
import com.eatssu.android.presentation.util.CalendarUtil
import com.eatssu.android.presentation.util.CalendarUtil.daysInWeekArray
import com.eatssu.android.presentation.util.CalendarUtil.monthYearFromDate
Expand All @@ -28,7 +27,7 @@ import java.time.LocalDate
@AndroidEntryPoint
class CafeteriaFragment : BaseFragment<FragmentCafeteriaBinding>(
ScreenId.HOME_MAIN
), OnItemListener {
), CalendarAdapter.OnItemListener {

private val mainViewModel by activityViewModels<MainViewModel>()

Expand Down Expand Up @@ -86,9 +85,9 @@ class CafeteriaFragment : BaseFragment<FragmentCafeteriaBinding>(
}

private fun setWeekView() {
monthYearText?.text = CalendarUtil.selectedDate?.let { monthYearFromDate(it) }
val days = CalendarUtil.selectedDate?.let { daysInWeekArray(it) }
val calendarAdapter = days?.let { CalendarAdapter(it, this) }
monthYearText?.text = monthYearFromDate(CalendarUtil.selectedDate)
val days = daysInWeekArray(CalendarUtil.selectedDate)
val calendarAdapter = CalendarAdapter(days, this)
val gridLayoutManager = GridLayoutManager(requireContext(), 7)

calendarRecyclerView?.layoutManager = gridLayoutManager
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class ModifyViewModel @Inject constructor(
ToastType.ERROR
)
)
return@launch
}

_uiEvent.emit(UiEvent.NavigateBack)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,12 @@ class WriteReviewViewModel @Inject constructor(
val compressedFile = compressImage(context, originalFile)
if (compressedFile != null && compressedFile.exists()) {
imageUrl = getImageUrlUseCase(compressedFile)
if (imageUrl == null) {
_uiState.value = UiState.Success(editing) // 되돌림
_uiEvent.emit(UiEvent.ShowToast(UiText.StringResource(R.string.toast_image_upload_failed), ToastType.ERROR))
originalFile.delete()
return@launch
}
_uiEvent.emit(UiEvent.ShowToast(UiText.StringResource(R.string.toast_image_upload_success), ToastType.SUCCESS))

// 원본 파일 삭제 (압축된 파일만 유지)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,6 @@ internal fun MyReviewListScreen(
)
}
}

null -> TODO()
}
}

Expand Down Expand Up @@ -325,4 +323,4 @@ fun ReviewListEmptyPreview() {
)
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -209,10 +209,30 @@ class UserInfoViewModel @Inject constructor(
fun saveUserInfo() {
viewModelScope.launch {
val currentState = _uiState.value as? UiState.Success ?: return@launch
val data = currentState.data

if ((data.isCollegeChanged || data.isDepartmentChanged) && data.selectedDepartment == null) {
_uiEvent.emit(
UiEvent.ShowToast(
UiText.StringResource(R.string.toast_department_required),
ToastType.ERROR
)
)
return@launch
}

if ((data.isCollegeChanged || data.isDepartmentChanged) && data.selectedCollege == null) {
_uiEvent.emit(
UiEvent.ShowToast(
UiText.StringResource(R.string.toast_college_required),
ToastType.ERROR
)
)
return@launch
}

_uiState.update { UiState.Loading }

val data = currentState.data
var nicknameUpdated = false
var departmentUpdated = false

Expand All @@ -234,8 +254,29 @@ class UserInfoViewModel @Inject constructor(

// 학과/단과대 변경이 있는 경우
if (data.isCollegeChanged || data.isDepartmentChanged) {
val department = data.selectedDepartment ?: return@launch
val college = data.selectedCollege ?: return@launch
val department = data.selectedDepartment
if (department == null) {
_uiState.value = UiState.Success(data)
_uiEvent.emit(
UiEvent.ShowToast(
UiText.StringResource(R.string.toast_department_required),
ToastType.ERROR
)
)
return@launch
}

val college = data.selectedCollege
if (college == null) {
_uiState.value = UiState.Success(data)
_uiEvent.emit(
UiEvent.ShowToast(
UiText.StringResource(R.string.toast_college_required),
ToastType.ERROR
)
)
return@launch
}

val success = userRepository.setUserDepartment(department.departmentId)
if (!success) {
Expand Down
Loading