From c854029c2a93aebbb0918069beaa45ee19c7a2d0 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 14:36:39 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=ED=9A=8C=EC=9B=90=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=EC=A1=B0=ED=9A=8C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/members/me를 GetMemberProfileUseCase로 노출하고 MyViewModel이 진입 시 조회한다 - 더미 상태를 제거하고 프로필·학습 통계·주간 차트를 서버 값으로 채운다 - Position·CareerLevel 표기 라벨 매핑을 feature 모듈에 두고, 큐레이션 전(null)은 빈 라벨로 둔다 - 조회 실패 시 빈 상태를 유지하고 로그만 남긴다. 에러 화면 디자인이 아직 없다 - ktlintCheck, :feature:my:allTests, :domain:test, :shared:jvmTest 통과 --- .../domain/usecase/GetMemberProfileUseCase.kt | 20 ++++ feature/my/build.gradle.kts | 8 ++ .../gitit/feature/my/MemberProfileMapper.kt | 68 ++++++++++++ .../hytime/gitit/feature/my/MyRoute.kt | 4 +- .../hytime/gitit/feature/my/MyViewModel.kt | 56 +++++----- .../gitit/feature/my/FakeMemberRepository.kt | 41 +++++++ .../gitit/feature/my/MyViewModelTest.kt | 104 ++++++++++++++++++ .../kotlin/com/nexters/hytime/gitit/Di.kt | 5 + 8 files changed, 274 insertions(+), 32 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetMemberProfileUseCase.kt create mode 100644 feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt create mode 100644 feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/FakeMemberRepository.kt create mode 100644 feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetMemberProfileUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetMemberProfileUseCase.kt new file mode 100644 index 00000000..b002d67c --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetMemberProfileUseCase.kt @@ -0,0 +1,20 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.MemberProfile +import com.nexters.hytime.gitit.domain.repository.MemberRepository + +/** + * 현재 회원의 프로필과 학습 현황을 조회한다. + * + * @property repository 회원 정보를 제공하는 도메인 계약 + */ +class GetMemberProfileUseCase( + private val repository: MemberRepository, +) { + /** + * 마이 화면에 표시할 프로필을 가져온다. + * + * @return 조회된 프로필 또는 실패 원인 + */ + suspend operator fun invoke(): Result = repository.getMemberProfile() +} diff --git a/feature/my/build.gradle.kts b/feature/my/build.gradle.kts index dff9a22f..1e9b7297 100644 --- a/feature/my/build.gradle.kts +++ b/feature/my/build.gradle.kts @@ -6,11 +6,19 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core.designsystem) + implementation(projects.core.logging) + implementation(projects.domain) + + implementation(libs.koin.compose.viewmodel) implementation(libs.compose.material3) implementation(libs.compose.components.resources) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } } } diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt new file mode 100644 index 00000000..d1ab3213 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt @@ -0,0 +1,68 @@ +package com.nexters.hytime.gitit.feature.my + +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.DaySolvedCount +import com.nexters.hytime.gitit.domain.model.MemberProfile +import com.nexters.hytime.gitit.domain.model.Position + +/** + * 회원 프로필 도메인 모델을 마이 화면 상태로 변환한다. + * + * @return 프로필·학습 통계·주간 학습량이 채워진 화면 상태 + */ +internal fun MemberProfile.toUiState(): MyUiState = + MyUiState( + profile = + MyProfile( + name = name.orEmpty(), + email = email.orEmpty(), + developmentField = position.toDisplayLabel(), + learningLevel = careerLevel.toDisplayLabel(), + ), + stats = + listOf( + MyStudyStat(label = "이번 주", value = "${thisWeekSolvedCount}문제"), + MyStudyStat(label = "이번 달", value = "${thisMonthSolvedCount}문제"), + MyStudyStat(label = "연속 학습", value = "${streakDays}일"), + ), + weeklyStudy = weeklyChart.map(DaySolvedCount::toWeeklyStudy), + ) + +/** + * 요일별 문제 풀이량을 주간 차트 항목으로 변환한다. + * + * @return 요일 라벨과 풀이 수를 담은 차트 항목 + */ +private fun DaySolvedCount.toWeeklyStudy(): MyWeeklyStudy = + MyWeeklyStudy( + day = dayLabel, + solvedCount = count, + ) + +/** + * 개발 분야를 화면 표기 라벨로 변환한다. + * + * @return 표기 라벨. 큐레이션 전이라 값이 없으면 빈 문자열 + */ +internal fun Position?.toDisplayLabel(): String = + when (this) { + Position.BACKEND -> "Back-end" + Position.FRONTEND -> "Front-end" + Position.IOS -> "iOS" + Position.ANDROID -> "Android" + null -> "" + } + +/** + * 개발 수준을 화면 표기 라벨로 변환한다. + * + * @return 표기 라벨. 큐레이션 전이라 값이 없으면 빈 문자열 + */ +internal fun CareerLevel?.toDisplayLabel(): String = + when (this) { + CareerLevel.ENTRY -> "입문" + CareerLevel.JUNIOR -> "주니어" + CareerLevel.MIDDLE -> "미들" + CareerLevel.SENIOR -> "시니어" + null -> "" + } diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyRoute.kt index ba4bd676..42f2b541 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyRoute.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyRoute.kt @@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest +import org.koin.compose.viewmodel.koinViewModel /** * 마이 화면의 진입점(Route)이다. @@ -22,7 +22,7 @@ fun MyRoute( onNavigateToBookmark: () -> Unit, onNavigateToSettings: () -> Unit, ) { - val viewModel = viewModel { MyViewModel() } + val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModel.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModel.kt index 832e7435..a11d8ce5 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModel.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModel.kt @@ -2,6 +2,8 @@ package com.nexters.hytime.gitit.feature.my import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -12,9 +14,15 @@ import kotlinx.coroutines.launch /** * 마이 화면의 상태와 사용자 의도를 관리한다. + * + * @property getMemberProfile 회원 프로필과 학습 현황을 조회하는 유스케이스 */ -class MyViewModel : ViewModel() { - private val _uiState = MutableStateFlow(dummyMyUiState) +class MyViewModel( + private val getMemberProfile: GetMemberProfileUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + private val _uiState = MutableStateFlow(MyUiState()) /** * 마이 화면이 구독할 현재 UI 상태이다. @@ -28,6 +36,10 @@ class MyViewModel : ViewModel() { */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadProfile() + } + /** * 마이 화면에서 발생한 사용자 의도를 처리한다. * @@ -43,35 +55,19 @@ class MyViewModel : ViewModel() { } } + /** + * 회원 프로필을 조회해 화면 상태를 채운다. + * 실패하면 빈 상태를 유지하고 원인을 로그로 남긴다. + */ + private fun loadProfile() { + viewModelScope.launch { + getMemberProfile() + .onSuccess { profile -> _uiState.value = profile.toUiState() } + .onFailure { error -> logger.e(throwable = error) { "회원 프로필 조회 실패" } } + } + } + private fun emit(sideEffect: MySideEffect) { viewModelScope.launch { _sideEffects.emit(sideEffect) } } } - -/** domain/data 연동 전까지 화면에 표시할 더미 마이 학습 상태다. */ -private val dummyMyUiState = - MyUiState( - profile = - MyProfile( - name = "김이박", - email = "kimlee@github.io", - developmentField = "Back-end", - learningLevel = "입문", - ), - stats = - listOf( - MyStudyStat(label = "이번 주", value = "13문제"), - MyStudyStat(label = "이번 달", value = "47문제"), - MyStudyStat(label = "연속 학습", value = "7일"), - ), - weeklyStudy = - listOf( - MyWeeklyStudy(day = "월", solvedCount = 14), - MyWeeklyStudy(day = "화", solvedCount = 8), - MyWeeklyStudy(day = "수", solvedCount = 18), - MyWeeklyStudy(day = "목", solvedCount = 11), - MyWeeklyStudy(day = "금", solvedCount = 14), - MyWeeklyStudy(day = "토", solvedCount = 11), - MyWeeklyStudy(day = "일", solvedCount = 18), - ), - ) diff --git a/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/FakeMemberRepository.kt b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/FakeMemberRepository.kt new file mode 100644 index 00000000..1e7d4eb5 --- /dev/null +++ b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/FakeMemberRepository.kt @@ -0,0 +1,41 @@ +package com.nexters.hytime.gitit.feature.my + +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.DeviceInfo +import com.nexters.hytime.gitit.domain.model.MemberCuration +import com.nexters.hytime.gitit.domain.model.MemberProfile +import com.nexters.hytime.gitit.domain.model.Position +import com.nexters.hytime.gitit.domain.repository.MemberRepository + +/** + * 지정한 결과만 돌려주는 테스트용 회원 리포지토리다. + * + * @property profileResult 프로필 조회에 돌려줄 결과 + * @property updateResult 분야·수준 변경에 돌려줄 결과 + */ +internal class FakeMemberRepository( + private val profileResult: Result = Result.failure(IllegalStateException("설정되지 않은 호출입니다.")), + private val updateResult: Result = Result.success(Unit), +) : MemberRepository { + /** 마지막으로 변경 요청한 개발 분야다. */ + var updatedPosition: Position? = null + + /** 마지막으로 변경 요청한 개발 수준이다. */ + var updatedCareerLevel: CareerLevel? = null + + override suspend fun getMemberProfile(): Result = profileResult + + override suspend fun registerDevice(deviceInfo: DeviceInfo): Result = error("호출되면 안 됩니다.") + + override suspend fun curateMember(curation: MemberCuration): Result = error("호출되면 안 됩니다.") + + override suspend fun updatePosition(position: Position): Result { + updatedPosition = position + return updateResult + } + + override suspend fun updateCareerLevel(careerLevel: CareerLevel): Result { + updatedCareerLevel = careerLevel + return updateResult + } +} diff --git a/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt new file mode 100644 index 00000000..e7cea45b --- /dev/null +++ b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt @@ -0,0 +1,104 @@ +@file:Suppress("ktlint:standard:function-naming") + +package com.nexters.hytime.gitit.feature.my + +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.DaySolvedCount +import com.nexters.hytime.gitit.domain.model.MemberProfile +import com.nexters.hytime.gitit.domain.model.Position +import com.nexters.hytime.gitit.domain.repository.MemberRepository +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** 마이 화면 상태가 회원 프로필 조회 결과로 채워지는지 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) +class MyViewModelTest { + private val dispatcher = StandardTestDispatcher() + + /** ViewModel의 Main dispatcher를 테스트 dispatcher로 교체한다. */ + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + /** 테스트 이후 Main dispatcher를 복원한다. */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** 프로필 조회가 성공하면 라벨 변환을 거쳐 화면 상태를 채운다. */ + @Test + fun init_프로필조회에성공하면_화면상태를채운다() { + runTest(dispatcher) { + val viewModel = MyViewModel(GetMemberProfileUseCase(FakeMemberRepository(Result.success(PROFILE)))) + runCurrent() + + val state = viewModel.uiState.value + assertEquals("김이박", state.profile.name) + assertEquals("gitit@example.com", state.profile.email) + assertEquals("Back-end", state.profile.developmentField) + assertEquals("주니어", state.profile.learningLevel) + assertEquals(listOf("13문제", "47문제", "7일"), state.stats.map(MyStudyStat::value)) + assertEquals(listOf("월", "화"), state.weeklyStudy.map(MyWeeklyStudy::day)) + assertEquals(listOf(3, 0), state.weeklyStudy.map(MyWeeklyStudy::solvedCount)) + } + } + + /** 큐레이션 전이라 프로필 값이 비어 있으면 빈 라벨로 표시한다. */ + @Test + fun init_큐레이션전프로필이면_빈라벨로표시한다() { + val emptyProfile = + PROFILE.copy(name = null, email = null, position = null, careerLevel = null) + + runTest(dispatcher) { + val viewModel = MyViewModel(GetMemberProfileUseCase(FakeMemberRepository(Result.success(emptyProfile)))) + runCurrent() + + val profile = viewModel.uiState.value.profile + assertEquals("", profile.name) + assertEquals("", profile.developmentField) + assertEquals("", profile.learningLevel) + } + } + + /** 프로필 조회가 실패하면 초기 빈 상태를 유지한다. */ + @Test + fun init_프로필조회에실패하면_빈상태를유지한다() { + runTest(dispatcher) { + val viewModel = + MyViewModel(GetMemberProfileUseCase(FakeMemberRepository(Result.failure(IllegalStateException("네트워크 오류"))))) + runCurrent() + + assertEquals(MyUiState(), viewModel.uiState.value) + } + } + + private companion object { + private val PROFILE = + MemberProfile( + name = "김이박", + email = "gitit@example.com", + position = Position.BACKEND, + careerLevel = CareerLevel.JUNIOR, + thisWeekSolvedCount = 13, + thisMonthSolvedCount = 47, + streakDays = 7, + weeklyChart = + listOf( + DaySolvedCount(dayLabel = "월", count = 3), + DaySolvedCount(dayLabel = "화", count = 0), + ), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index 1b92c1e4..f16b3d6b 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -4,10 +4,13 @@ import com.nexters.hytime.gitit.domain.auth.AuthTokenProvider import com.nexters.hytime.gitit.domain.auth.LoginSessionStorage import com.nexters.hytime.gitit.domain.repository.AuthRepository import com.nexters.hytime.gitit.domain.repository.GitHubRepositoryRepository +import com.nexters.hytime.gitit.domain.repository.MemberRepository import com.nexters.hytime.gitit.domain.repository.ProjectRepository +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase import com.nexters.hytime.gitit.domain.usecase.LoadGitHubRepositoryUseCase import com.nexters.hytime.gitit.domain.usecase.RegisterProjectUseCase import com.nexters.hytime.gitit.domain.usecase.SignInUseCase +import com.nexters.hytime.gitit.feature.my.MyViewModel import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailViewModel import com.nexters.hytime.gitit.feature.quiz.create.QuizCreateViewModel import com.nexters.hytime.gitit.feature.quiz.create.session.QuizCreateRetryHandler @@ -36,8 +39,10 @@ val appModule: Module = ) } single { LoadGitHubRepositoryUseCase(repository = get()) } + single { GetMemberProfileUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } + viewModel { MyViewModel(getMemberProfile = get()) } viewModel { params -> ProjectDetailViewModel(projectId = params.get()) } viewModel { ProjectLoadViewModel(loadGitHubRepository = get()) } single { QuizCreateStore() } From 4e05577f7f2bbd5699fdf0cba655d42e687bf680 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 14:40:58 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=C2=B7=ED=99=88=EC=97=90?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/projects를 GetProjectsUseCase로 노출하고 두 화면이 진입 시 조회한다 - 더미 목록을 제거하고 저장소 이름·기술 스택·현재 세트·진행률을 서버 값으로 채운다 - 홈 Refresh 인텐트가 목록을 다시 조회하도록 연결했다 - 페이지 크기는 서버 기본값(10)을 쓴다. 무한 스크롤은 화면 디자인이 없어 다루지 않았다 - ktlintCheck, :feature:projectlist:jvmTest, :feature:home:jvmTest, :domain:test, :shared:jvmTest 통과 --- .../domain/usecase/GetProjectsUseCase.kt | 25 +++ feature/home/build.gradle.kts | 8 + .../gitit/feature/home/HomeProjectMapper.kt | 18 ++ .../hytime/gitit/feature/home/HomeRoute.kt | 4 +- .../gitit/feature/home/HomeViewModel.kt | 62 +++--- .../feature/home/FakeHomeProjectRepository.kt | 59 ++++++ .../gitit/feature/home/HomeScreenTest.kt | 42 ++++- feature/projectlist/build.gradle.kts | 8 + .../feature/projectlist/ProjectListMapper.kt | 18 ++ .../feature/projectlist/ProjectListRoute.kt | 4 +- .../projectlist/ProjectListViewModel.kt | 59 +++--- .../projectlist/FakeProjectListRepository.kt | 59 ++++++ .../projectlist/ProjectListViewModelTest.kt | 178 ++++++++++++++---- .../kotlin/com/nexters/hytime/gitit/Di.kt | 6 + 14 files changed, 437 insertions(+), 113 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetProjectsUseCase.kt create mode 100644 feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt create mode 100644 feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/FakeHomeProjectRepository.kt create mode 100644 feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListMapper.kt create mode 100644 feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetProjectsUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetProjectsUseCase.kt new file mode 100644 index 00000000..76fe76d9 --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetProjectsUseCase.kt @@ -0,0 +1,25 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 등록한 프로젝트 목록을 페이지 단위로 조회한다. + * + * @property repository 프로젝트 정보를 제공하는 도메인 계약 + */ +class GetProjectsUseCase( + private val repository: ProjectRepository, +) { + /** + * 프로젝트 한 페이지를 가져온다. + * + * @param page 0부터 시작하는 페이지 번호 + * @param size 한 페이지에 담을 프로젝트 수 + * @return 조회된 페이지 또는 실패 원인 + */ + suspend operator fun invoke( + page: Int = ProjectRepository.DEFAULT_PAGE, + size: Int = ProjectRepository.DEFAULT_PAGE_SIZE, + ): Result = repository.getProjects(page, size) +} diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 8961a243..98fade9d 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -6,10 +6,18 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core.designsystem) + implementation(projects.core.logging) + implementation(projects.domain) + + implementation(libs.koin.compose.viewmodel) implementation(libs.compose.material3) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } } } diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt new file mode 100644 index 00000000..546a0865 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt @@ -0,0 +1,18 @@ +package com.nexters.hytime.gitit.feature.home + +import com.nexters.hytime.gitit.domain.model.ProjectSummary + +/** + * 프로젝트 요약 도메인 모델을 홈 학습 카드 모델로 변환한다. + * + * @return 홈 화면에 표시할 학습 카드 + */ +internal fun ProjectSummary.toLearningProject(): HomeLearningProject = + HomeLearningProject( + id = projectId, + title = repositoryName, + technologies = techStack.joinToString(" · "), + setLabel = currentSetLabel, + description = currentSetTitle, + progress = overallProgressPercent / 100f, + ) diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeRoute.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeRoute.kt index e216ffed..25ff4823 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeRoute.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeRoute.kt @@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest +import org.koin.compose.viewmodel.koinViewModel /** * 홈 기능의 상태와 이벤트를 화면에 연결하는 진입점이다. @@ -28,7 +28,7 @@ fun HomeRoute( onNavigateToProjectDetail: (String) -> Unit = {}, onNavigateToQuiz: (String) -> Unit = {}, ) { - val viewModel = viewModel { HomeViewModel() } + val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt index 41ff75f2..a4395a05 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt @@ -1,18 +1,28 @@ package com.nexters.hytime.gitit.feature.home import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase +import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** * 홈 화면의 상태와 사용자 의도를 관리한다. + * + * @property getProjects 이어서 학습할 프로젝트 목록을 조회하는 유스케이스 */ -class HomeViewModel : ViewModel() { - private val _uiState = MutableStateFlow(HomeUiState(learningProjects = dummyLearningProjects)) +class HomeViewModel( + private val getProjects: GetProjectsUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + private val _uiState = MutableStateFlow(HomeUiState()) /** * 홈 화면이 구독할 현재 UI 상태이다. @@ -26,6 +36,10 @@ class HomeViewModel : ViewModel() { */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadProjects() + } + /** * 홈 화면에서 발생한 사용자 의도를 처리한다. * @@ -33,7 +47,7 @@ class HomeViewModel : ViewModel() { */ fun onIntent(intent: HomeIntent) { when (intent) { - HomeIntent.Refresh -> Unit + HomeIntent.Refresh -> loadProjects() HomeIntent.HomeTabClick -> Unit HomeIntent.LoadProjectClick -> emit(HomeSideEffect.NavigateToProjectLoad) HomeIntent.ViewAllProjectsClick -> emit(HomeSideEffect.NavigateToProjectList) @@ -48,33 +62,17 @@ class HomeViewModel : ViewModel() { private fun emit(sideEffect: HomeSideEffect) { _sideEffects.tryEmit(sideEffect) } -} -/** 실제 데이터 연동 전 홈 카드 동작을 확인하기 위한 임시 목록이다. */ -private val dummyLearningProjects = - listOf( - HomeLearningProject( - id = "nexters", - title = "Nexters", - technologies = "Kotlin · Compose · Coroutines", - setLabel = "Set 1", - description = "Compose 핵심 개념", - progress = 0.21f, - ), - HomeLearningProject( - id = "now-in-android", - title = "Now in\nAndroid", - technologies = "Kotlin · Compose · Coroutines", - setLabel = "Set 1", - description = "Compose 핵심 개념", - progress = 0.21f, - ), - HomeLearningProject( - id = "compose-samples", - title = "Compose\nSamples", - technologies = "Kotlin · Compose", - setLabel = "Set 2", - description = "상태 관리 익히기", - progress = 0.42f, - ), - ) + /** + * 프로젝트 목록을 조회해 학습 카드를 채운다. + * 실패하면 이전 목록을 유지하고 원인을 로그로 남긴다. + */ + private fun loadProjects() { + viewModelScope.launch { + getProjects() + .onSuccess { page -> + _uiState.value = _uiState.value.copy(learningProjects = page.items.map { it.toLearningProject() }) + }.onFailure { error -> logger.e(throwable = error) { "홈 프로젝트 목록 조회 실패" } } + } + } +} diff --git a/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/FakeHomeProjectRepository.kt b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/FakeHomeProjectRepository.kt new file mode 100644 index 00000000..f7a0db0d --- /dev/null +++ b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/FakeHomeProjectRepository.kt @@ -0,0 +1,59 @@ +package com.nexters.hytime.gitit.feature.home + +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel +import com.nexters.hytime.gitit.domain.model.ProjectRegistration +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 홈 목록 조회에 지정한 결과만 돌려주는 테스트용 프로젝트 리포지토리다. + * + * @property pageResult 목록 조회에 돌려줄 결과 + */ +internal class FakeHomeProjectRepository( + private val pageResult: Result, +) : ProjectRepository { + override suspend fun registerProject( + githubRepoUrl: String, + quizLevel: ProjectQuizLevel, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjects( + page: Int, + size: Int, + ): Result = pageResult + + override suspend fun getProjectDetail(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun getLearningSet( + projectId: String, + setId: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitChoiceAnswer( + projectId: String, + questionId: String, + selectedIndex: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitEssayAnswer( + projectId: String, + questionId: String, + text: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun bookmarkQuestion( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getBookmarkedQuestions(projectId: String?): Result = error("호출되면 안 됩니다.") +} diff --git a/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt index 346f0b95..f8e21ee1 100644 --- a/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt +++ b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt @@ -2,15 +2,39 @@ package com.nexters.hytime.gitit.feature.home import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals /** 홈 화면의 카드 배치와 입력 처리를 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) class HomeScreenTest { + private val dispatcher = StandardTestDispatcher() + + /** ViewModel의 Main dispatcher를 테스트 dispatcher로 교체한다. */ + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + /** 테스트 이후 Main dispatcher를 복원한다. */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + /** 화면 높이에 따라 카드 크기가 Figma의 최소·최대 범위로 제한된다. */ @Test fun learningCardSize_viewportHeight_returnsMinAndMaxSize() { @@ -43,8 +67,8 @@ class HomeScreenTest { /** 홈 카드의 재생 버튼이 문제 풀이 이동 이벤트를 발행하는지 검증한다. */ @Test fun learningPlayClick_emitsNavigateToQuiz() = - runBlocking { - val viewModel = HomeViewModel() + runTest(dispatcher) { + val viewModel = createViewModel() val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } viewModel.onIntent(HomeIntent.LearningPlayClick("project-1")) @@ -55,12 +79,20 @@ class HomeScreenTest { /** 홈 카드를 선택하면 프로젝트 상세 이동 이벤트를 발행하는지 검증한다. */ @Test fun learningCardClick_emitsNavigateToProjectDetail() = - runBlocking { - val viewModel = HomeViewModel() + runTest(dispatcher) { + val viewModel = createViewModel() val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } viewModel.onIntent(HomeIntent.LearningCardClick("project-1")) assertEquals(HomeSideEffect.NavigateToProjectDetail("project-1"), sideEffect.await()) } + + private fun createViewModel(): HomeViewModel = + HomeViewModel( + getProjects = + GetProjectsUseCase( + FakeHomeProjectRepository(Result.success(ProjectPage(items = emptyList(), hasNext = false))), + ), + ) } diff --git a/feature/projectlist/build.gradle.kts b/feature/projectlist/build.gradle.kts index dff9a22f..1e9b7297 100644 --- a/feature/projectlist/build.gradle.kts +++ b/feature/projectlist/build.gradle.kts @@ -6,11 +6,19 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core.designsystem) + implementation(projects.core.logging) + implementation(projects.domain) + + implementation(libs.koin.compose.viewmodel) implementation(libs.compose.material3) implementation(libs.compose.components.resources) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } } } diff --git a/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListMapper.kt b/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListMapper.kt new file mode 100644 index 00000000..fef4fd79 --- /dev/null +++ b/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListMapper.kt @@ -0,0 +1,18 @@ +package com.nexters.hytime.gitit.feature.projectlist + +import com.nexters.hytime.gitit.domain.model.ProjectSummary + +/** + * 프로젝트 요약 도메인 모델을 목록 카드 모델로 변환한다. + * + * @return 목록 화면에 표시할 프로젝트 카드 + */ +internal fun ProjectSummary.toListItem(): ProjectListItem = + ProjectListItem( + id = projectId, + title = repositoryName, + techStack = techStack.joinToString(" · "), + setLabel = currentSetLabel, + recentSetTitle = currentSetTitle, + progress = overallProgressPercent, + ) diff --git a/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListRoute.kt b/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListRoute.kt index ded3c2f6..373eea89 100644 --- a/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListRoute.kt +++ b/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListRoute.kt @@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest +import org.koin.compose.viewmodel.koinViewModel /** * 프로젝트 리스트 화면의 진입점(Route)이다. @@ -30,7 +30,7 @@ fun ProjectListRoute( onNavigateToProjectDetail: (String) -> Unit, onNavigateToQuiz: (String) -> Unit, ) { - val viewModel = viewModel { ProjectListViewModel() } + val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { diff --git a/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModel.kt b/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModel.kt index dd721f9b..31d49d0b 100644 --- a/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModel.kt +++ b/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModel.kt @@ -1,6 +1,9 @@ package com.nexters.hytime.gitit.feature.projectlist import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase +import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -8,12 +11,19 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch /** * 프로젝트 리스트 화면의 상태와 사용자 의도를 관리한다. + * + * @property getProjects 등록한 프로젝트 목록을 조회하는 유스케이스 */ -class ProjectListViewModel : ViewModel() { - private val _uiState = MutableStateFlow(ProjectListUiState(projects = dummyProjects)) +class ProjectListViewModel( + private val getProjects: GetProjectsUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + private val _uiState = MutableStateFlow(ProjectListUiState()) /** * 프로젝트 리스트 화면이 구독할 현재 UI 상태이다. @@ -27,6 +37,10 @@ class ProjectListViewModel : ViewModel() { */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadProjects() + } + /** * 프로젝트 리스트 화면에서 발생한 사용자 의도를 처리한다. * @@ -70,33 +84,16 @@ class ProjectListViewModel : ViewModel() { private fun emit(sideEffect: ProjectListSideEffect) { _sideEffects.tryEmit(sideEffect) } -} -/** domain/data 연동 전까지 화면에 표시할 더미 프로젝트 목록이다. */ -private val dummyProjects = - listOf( - ProjectListItem( - id = "now-in-android-1", - title = "Now in\nAndroid", - techStack = "Kotlin · Compose · Coroutines", - setLabel = "Set 1", - recentSetTitle = "Compose 핵심 개념", - progress = 65, - ), - ProjectListItem( - id = "now-in-android-2", - title = "Now in\nAndroid", - techStack = "Kotlin · Compose · Coroutines", - setLabel = "Set 1", - recentSetTitle = "Compose 핵심 개념", - progress = 65, - ), - ProjectListItem( - id = "now-in-android-3", - title = "Now in\nAndroid", - techStack = "Kotlin · Compose · Coroutines", - setLabel = "Set 1", - recentSetTitle = "Compose 핵심 개념", - progress = 65, - ), - ) + /** + * 프로젝트 목록을 조회해 화면 상태를 채운다. + * 실패하면 빈 목록을 유지하고 원인을 로그로 남긴다. + */ + private fun loadProjects() { + viewModelScope.launch { + getProjects() + .onSuccess { page -> setState { copy(projects = page.items.map { it.toListItem() }) } } + .onFailure { error -> logger.e(throwable = error) { "프로젝트 목록 조회 실패" } } + } + } +} diff --git a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt new file mode 100644 index 00000000..ab3c4aa4 --- /dev/null +++ b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt @@ -0,0 +1,59 @@ +package com.nexters.hytime.gitit.feature.projectlist + +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel +import com.nexters.hytime.gitit.domain.model.ProjectRegistration +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 목록 조회에 지정한 결과만 돌려주는 테스트용 프로젝트 리포지토리다. + * + * @property pageResult 목록 조회에 돌려줄 결과 + */ +internal class FakeProjectListRepository( + private val pageResult: Result, +) : ProjectRepository { + override suspend fun registerProject( + githubRepoUrl: String, + quizLevel: ProjectQuizLevel, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjects( + page: Int, + size: Int, + ): Result = pageResult + + override suspend fun getProjectDetail(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun getLearningSet( + projectId: String, + setId: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitChoiceAnswer( + projectId: String, + questionId: String, + selectedIndex: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitEssayAnswer( + projectId: String, + questionId: String, + text: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun bookmarkQuestion( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getBookmarkedQuestions(projectId: String?): Result = error("호출되면 안 됩니다.") +} diff --git a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt index e32e6870..3c495d7d 100644 --- a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt +++ b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt @@ -1,20 +1,79 @@ +@file:Suppress("ktlint:standard:function-naming") + package com.nexters.hytime.gitit.feature.projectlist +import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.model.ProjectSummary +import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -/** 프로젝트 리스트 ViewModel의 화면 이동 이벤트를 검증한다. */ +/** 프로젝트 리스트 ViewModel의 목록 조회와 화면 이동 이벤트를 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) class ProjectListViewModelTest { + private val dispatcher = StandardTestDispatcher() + + /** ViewModel의 Main dispatcher를 테스트 dispatcher로 교체한다. */ + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + /** 테스트 이후 Main dispatcher를 복원한다. */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** 목록 조회가 성공하면 카드 모델로 변환해 화면 상태를 채운다. */ + @Test + fun init_목록조회에성공하면_카드목록을채운다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + + val projects = viewModel.uiState.value.projects + assertEquals(listOf("p1", "p2", "p3"), projects.map(ProjectListItem::id)) + assertEquals("react", projects.first().title) + assertEquals("TypeScript · JavaScript", projects.first().techStack) + assertEquals("Set 1", projects.first().setLabel) + assertEquals(40, projects.first().progress) + } + } + + /** 목록 조회가 실패하면 빈 목록을 유지한다. */ + @Test + fun init_목록조회에실패하면_빈목록을유지한다() { + runTest(dispatcher) { + val viewModel = + ProjectListViewModel( + getProjects = GetProjectsUseCase(FakeProjectListRepository(Result.failure(IllegalStateException("오류")))), + ) + runCurrent() + + assertEquals(emptyList(), viewModel.uiState.value.projects) + } + } + /** 삭제 메뉴와 뒤로가기가 백스택 이동 이벤트를 발행하는지 검증한다. */ @Test - fun deleteModeIntent_clickAndBack_emitsNavigationEvents() = - runBlocking { - val viewModel = ProjectListViewModel() + fun deleteModeIntent_clickAndBack_emitsNavigationEvents() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() val navigateToDelete = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } viewModel.onIntent(ProjectListIntent.DeleteMenuClick) @@ -24,68 +83,105 @@ class ProjectListViewModelTest { viewModel.onIntent(ProjectListIntent.DeleteModeBackClick) assertEquals(ProjectListSideEffect.NavigateBack, navigateBack.await()) } + } /** 삭제 확인 모달에서 확인해야 선택한 프로젝트가 목록에서 제거되는지 검증한다. */ @Test fun confirmDeleteClick_projectSelected_removesOnlySelectedProject() { - val viewModel = ProjectListViewModel() - val deletedProjectId = - viewModel.uiState.value.projects - .first() - .id - - viewModel.onIntent(ProjectListIntent.DeleteProjectClick(deletedProjectId)) - - assertEquals(deletedProjectId, viewModel.uiState.value.pendingDeleteProjectId) - assertEquals(3, viewModel.uiState.value.projects.size) - - viewModel.onIntent(ProjectListIntent.ConfirmDeleteClick) - - assertFalse( - viewModel.uiState.value.projects - .any { it.id == deletedProjectId }, - ) - assertEquals(2, viewModel.uiState.value.projects.size) - assertEquals(null, viewModel.uiState.value.pendingDeleteProjectId) + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + val deletedProjectId = + viewModel.uiState.value.projects + .first() + .id + + viewModel.onIntent(ProjectListIntent.DeleteProjectClick(deletedProjectId)) + + assertEquals(deletedProjectId, viewModel.uiState.value.pendingDeleteProjectId) + assertEquals(3, viewModel.uiState.value.projects.size) + + viewModel.onIntent(ProjectListIntent.ConfirmDeleteClick) + runCurrent() + + assertFalse( + viewModel.uiState.value.projects + .any { it.id == deletedProjectId }, + ) + assertEquals(2, viewModel.uiState.value.projects.size) + assertEquals(null, viewModel.uiState.value.pendingDeleteProjectId) + } } /** 삭제 확인 모달을 취소하면 프로젝트 목록을 유지하는지 검증한다. */ @Test fun dismissDeleteClick_projectSelected_keepsProjects() { - val viewModel = ProjectListViewModel() - val deletedProjectId = - viewModel.uiState.value.projects - .first() - .id - - viewModel.onIntent(ProjectListIntent.DeleteProjectClick(deletedProjectId)) - viewModel.onIntent(ProjectListIntent.DismissDeleteClick) - - assertEquals(3, viewModel.uiState.value.projects.size) - assertEquals(null, viewModel.uiState.value.pendingDeleteProjectId) + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + val deletedProjectId = + viewModel.uiState.value.projects + .first() + .id + + viewModel.onIntent(ProjectListIntent.DeleteProjectClick(deletedProjectId)) + viewModel.onIntent(ProjectListIntent.DismissDeleteClick) + + assertEquals(3, viewModel.uiState.value.projects.size) + assertEquals(null, viewModel.uiState.value.pendingDeleteProjectId) + } } /** 프로젝트 카드의 플레이 버튼이 문제 풀이 이동 이벤트를 발행하는지 검증한다. */ @Test - fun playProjectClick_emitsNavigateToQuiz() = - runBlocking { - val viewModel = ProjectListViewModel() + fun playProjectClick_emitsNavigateToQuiz() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } viewModel.onIntent(ProjectListIntent.PlayProjectClick("project-1")) assertEquals(ProjectListSideEffect.NavigateToQuiz("project-1"), sideEffect.await()) } + } /** 프로젝트 카드를 선택하면 프로젝트 상세 이동 이벤트를 발행하는지 검증한다. */ @Test - fun projectClick_emitsNavigateToProjectDetail() = - runBlocking { - val viewModel = ProjectListViewModel() + fun projectClick_emitsNavigateToProjectDetail() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } viewModel.onIntent(ProjectListIntent.ProjectClick("project-1")) assertEquals(ProjectListSideEffect.NavigateToProjectDetail("project-1"), sideEffect.await()) } + } + + private fun createViewModel(): ProjectListViewModel = + ProjectListViewModel( + getProjects = GetProjectsUseCase(FakeProjectListRepository(Result.success(PAGE))), + ) + + private companion object { + private val PAGE = + ProjectPage( + items = + listOf("p1", "p2", "p3").map { id -> + ProjectSummary( + projectId = id, + repositoryName = "react", + repositoryImageUrl = "https://example.com/a.png", + techStack = listOf("TypeScript", "JavaScript"), + currentSetLabel = "Set 1", + currentSetTitle = "라우팅 흐름 따라가기", + nextProblemId = "q1", + overallProgressPercent = 40, + ) + }, + hasNext = false, + ) + } } diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index f16b3d6b..d0d74f6e 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -7,11 +7,14 @@ import com.nexters.hytime.gitit.domain.repository.GitHubRepositoryRepository import com.nexters.hytime.gitit.domain.repository.MemberRepository import com.nexters.hytime.gitit.domain.repository.ProjectRepository import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import com.nexters.hytime.gitit.domain.usecase.LoadGitHubRepositoryUseCase import com.nexters.hytime.gitit.domain.usecase.RegisterProjectUseCase import com.nexters.hytime.gitit.domain.usecase.SignInUseCase +import com.nexters.hytime.gitit.feature.home.HomeViewModel import com.nexters.hytime.gitit.feature.my.MyViewModel import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailViewModel +import com.nexters.hytime.gitit.feature.projectlist.ProjectListViewModel import com.nexters.hytime.gitit.feature.quiz.create.QuizCreateViewModel import com.nexters.hytime.gitit.feature.quiz.create.session.QuizCreateRetryHandler import com.nexters.hytime.gitit.feature.quiz.create.session.QuizCreateStore @@ -40,9 +43,12 @@ val appModule: Module = } single { LoadGitHubRepositoryUseCase(repository = get()) } single { GetMemberProfileUseCase(repository = get()) } + single { GetProjectsUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } + viewModel { HomeViewModel(getProjects = get()) } + viewModel { ProjectListViewModel(getProjects = get()) } viewModel { params -> ProjectDetailViewModel(projectId = params.get()) } viewModel { ProjectLoadViewModel(loadGitHubRepository = get()) } single { QuizCreateStore() } From 8cfed6a79cbb6f58fc0dfeabafcd97b7c726f2a9 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 14:43:00 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=83=81=EC=84=B8=EC=97=90=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/projects/{projectId}를 GetProjectDetailUseCase로 노출하고 진입 시 조회한다 - 더미 상태를 제거하고 저장소 정보·학습 세트·전체 진행률을 서버 값으로 채운다 - 스타 수는 기존 카드 표기(3.6k)에 맞춰 소수 한 자리 k 표기로 축약한다 - 세트 진행률은 completedCount/problemCount로 계산하고 프로그레스 칸 수는 문제 수를 쓴다 - ktlintCheck, :feature:projectdetail:jvmTest, :domain:test, :shared:jvmTest 통과 --- .../domain/usecase/GetProjectDetailUseCase.kt | 21 +++ feature/projectdetail/build.gradle.kts | 6 + .../projectdetail/ProjectDetailMapper.kt | 43 +++++ .../projectdetail/ProjectDetailViewModel.kt | 48 +++--- .../FakeProjectDetailRepository.kt | 59 +++++++ .../ProjectDetailViewModelTest.kt | 148 ++++++++++++++++-- .../kotlin/com/nexters/hytime/gitit/Di.kt | 4 +- 7 files changed, 289 insertions(+), 40 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetProjectDetailUseCase.kt create mode 100644 feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailMapper.kt create mode 100644 feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetProjectDetailUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetProjectDetailUseCase.kt new file mode 100644 index 00000000..efa8c765 --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetProjectDetailUseCase.kt @@ -0,0 +1,21 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 프로젝트 상세 정보를 조회한다. + * + * @property repository 프로젝트 정보를 제공하는 도메인 계약 + */ +class GetProjectDetailUseCase( + private val repository: ProjectRepository, +) { + /** + * 상세 화면에 표시할 프로젝트 정보를 가져온다. + * + * @param projectId 조회할 프로젝트 식별자 + * @return 조회된 상세 정보 또는 실패 원인 + */ + suspend operator fun invoke(projectId: String): Result = repository.getProjectDetail(projectId) +} diff --git a/feature/projectdetail/build.gradle.kts b/feature/projectdetail/build.gradle.kts index fa738341..91c2601c 100644 --- a/feature/projectdetail/build.gradle.kts +++ b/feature/projectdetail/build.gradle.kts @@ -6,6 +6,8 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core.designsystem) + implementation(projects.core.logging) + implementation(projects.domain) implementation(libs.koin.core) implementation(libs.koin.compose) @@ -16,5 +18,9 @@ kotlin { implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } } } diff --git a/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailMapper.kt b/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailMapper.kt new file mode 100644 index 00000000..4e311345 --- /dev/null +++ b/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailMapper.kt @@ -0,0 +1,43 @@ +package com.nexters.hytime.gitit.feature.projectdetail + +import com.nexters.hytime.gitit.domain.model.LearningSetSummary +import com.nexters.hytime.gitit.domain.model.ProjectDetail + +/** + * 프로젝트 상세 도메인 모델을 화면 메타 정보로 변환한다. + * + * @return 상세 화면 상단에 표시할 프로젝트 정보 + */ +internal fun ProjectDetail.toProjectInfo(): ProjectInfo = + ProjectInfo( + name = repositoryName, + thumbnailUrl = repositoryImageUrl, + starCount = formatStarCount(starCount), + techStack = techStack.joinToString(" · "), + ) + +/** + * 학습 세트 요약을 상세 화면 목록 항목으로 변환한다. + * + * @return 세트 라벨·제목·진행률이 채워진 목록 항목 + */ +internal fun LearningSetSummary.toListItem(): LearningSetItem = + LearningSetItem( + id = setId, + title = label, + description = title, + progress = if (problemCount > 0) completedCount * 100 / problemCount else 0, + totalSteps = problemCount, + ) + +/** + * GitHub 스타 수를 카드 표기 문자열로 축약한다. + * + * @param count 스타 수 + * @return 1,000 미만은 그대로, 이상은 `3.6k`처럼 소수 한 자리 k 표기 + */ +internal fun formatStarCount(count: Int): String { + if (count < 1_000) return count.toString() + val tenths = count / 100 + return if (tenths % 10 == 0) "${tenths / 10}k" else "${tenths / 10}.${tenths % 10}k" +} diff --git a/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModel.kt b/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModel.kt index 7e76147b..c9fde0be 100644 --- a/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModel.kt +++ b/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModel.kt @@ -1,24 +1,32 @@ package com.nexters.hytime.gitit.feature.projectdetail import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase +import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** * 프로젝트 상세 화면의 단일 [ProjectDetailUiState]를 관리하는 ViewModel이다. * * 비즈니스 상태는 [setState]로만 변경하고, 일회성 부작용(네비게이션 등)은 - * [events]로 흘려보낸다. 현재는 더미 데이터를 채우기만 한다. + * [events]로 흘려보낸다. * * @property projectId 네비게이션 인자로 전달된 프로젝트 식별자 + * @property getProjectDetail 프로젝트 상세 정보를 조회하는 유스케이스 */ class ProjectDetailViewModel( val projectId: String, + private val getProjectDetail: GetProjectDetailUseCase, ) : ViewModel() { + private val logger = gitItLogger() + private val _uiState = MutableStateFlow(ProjectDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -26,7 +34,7 @@ class ProjectDetailViewModel( val events: SharedFlow = _events.asSharedFlow() init { - loadDummy() + loadProjectDetail() } /** @@ -91,26 +99,22 @@ class ProjectDetailViewModel( _events.tryEmit(event) } - private fun loadDummy() { - setState { - copy( - project = - ProjectInfo( - name = "Nexters", - thumbnailUrl = "", - starCount = "3.6k", - techStack = "Kotlin · Compose · Coroutines", - ), - learningSets = - listOf( - LearningSetItem("1", "Set 1", "아이디어 PT 핵심 내용 확인하기"), - LearningSetItem("2", "Set 2", "서비스 문제와 타깃 알아보기"), - LearningSetItem("3", "Set 3", "아이디어별 해결 방식 비교하기"), - LearningSetItem("4", "Set 4", "주요 기능과 사용 경험 확인하기"), - LearningSetItem("5", "Set 5", "발표 아이디어 종합 정리하기"), - ), - totalProgress = 0, - ) + /** + * 프로젝트 상세를 조회해 화면 상태를 채운다. + * 실패하면 로딩 상태(project = null)를 유지하고 원인을 로그로 남긴다. + */ + private fun loadProjectDetail() { + viewModelScope.launch { + getProjectDetail(projectId) + .onSuccess { detail -> + setState { + copy( + project = detail.toProjectInfo(), + learningSets = detail.sets.map { it.toListItem() }, + totalProgress = detail.overallProgressPercent, + ) + } + }.onFailure { error -> logger.e(throwable = error) { "프로젝트 상세 조회 실패" } } } } } diff --git a/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt new file mode 100644 index 00000000..b5e28230 --- /dev/null +++ b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt @@ -0,0 +1,59 @@ +package com.nexters.hytime.gitit.feature.projectdetail + +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel +import com.nexters.hytime.gitit.domain.model.ProjectRegistration +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 상세 조회에 지정한 결과만 돌려주는 테스트용 프로젝트 리포지토리다. + * + * @property detailResult 상세 조회에 돌려줄 결과 + */ +internal class FakeProjectDetailRepository( + private val detailResult: Result, +) : ProjectRepository { + override suspend fun registerProject( + githubRepoUrl: String, + quizLevel: ProjectQuizLevel, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjects( + page: Int, + size: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjectDetail(projectId: String): Result = detailResult + + override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun getLearningSet( + projectId: String, + setId: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitChoiceAnswer( + projectId: String, + questionId: String, + selectedIndex: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitEssayAnswer( + projectId: String, + questionId: String, + text: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun bookmarkQuestion( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getBookmarkedQuestions(projectId: String?): Result = error("호출되면 안 됩니다.") +} diff --git a/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModelTest.kt b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModelTest.kt index 1b34e3a3..e4b957a9 100644 --- a/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModelTest.kt +++ b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModelTest.kt @@ -1,60 +1,174 @@ +@file:Suppress("ktlint:standard:function-naming") + package com.nexters.hytime.gitit.feature.projectdetail +import com.nexters.hytime.gitit.domain.model.LearningSetSummary +import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.async import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue -/** 프로젝트 상세 ViewModel의 상태 변경을 검증한다. */ +/** 프로젝트 상세 ViewModel의 상세 조회와 상태 변경을 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) class ProjectDetailViewModelTest { + private val dispatcher = StandardTestDispatcher() + + /** ViewModel의 Main dispatcher를 테스트 dispatcher로 교체한다. */ + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + /** 테스트 이후 Main dispatcher를 복원한다. */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** 상세 조회가 성공하면 프로젝트 정보와 세트 목록을 화면 상태로 변환한다. */ + @Test + fun init_상세조회에성공하면_화면상태를채운다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + + val state = viewModel.uiState.value + assertEquals("react", state.project?.name) + assertEquals("3.6k", state.project?.starCount) + assertEquals("TypeScript · JavaScript", state.project?.techStack) + assertEquals(60, state.totalProgress) + assertEquals(listOf("s1"), state.learningSets.map(LearningSetItem::id)) + assertEquals("Set 1", state.learningSets.first().title) + assertEquals("라우팅 흐름 따라가기", state.learningSets.first().description) + assertEquals(60, state.learningSets.first().progress) + assertEquals(5, state.learningSets.first().totalSteps) + } + } + + /** 상세 조회가 실패하면 로딩 상태를 유지한다. */ + @Test + fun init_상세조회에실패하면_로딩상태를유지한다() { + runTest(dispatcher) { + val viewModel = + ProjectDetailViewModel( + projectId = "project-1", + getProjectDetail = + GetProjectDetailUseCase(FakeProjectDetailRepository(Result.failure(IllegalStateException("오류")))), + ) + runCurrent() + + assertNull(viewModel.uiState.value.project) + } + } + /** 더보기 메뉴 인텐트가 노출 상태를 켰다가 다시 끄는지 검증한다. */ @Test fun onMoreMenuClick_togglesMenuVisibility() { - val viewModel = ProjectDetailViewModel(projectId = "project-1") + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() - viewModel.onMoreMenuClick() - assertTrue(viewModel.uiState.value.showMoreMenu) + viewModel.onMoreMenuClick() + assertTrue(viewModel.uiState.value.showMoreMenu) - viewModel.onMoreMenuClick() - assertFalse(viewModel.uiState.value.showMoreMenu) + viewModel.onMoreMenuClick() + assertFalse(viewModel.uiState.value.showMoreMenu) + } } /** 더보기 메뉴 닫기 인텐트가 메뉴 노출 상태를 false로 바꾸는지 검증한다. */ @Test fun onDismissMoreMenu_hidesMenu() { - val viewModel = ProjectDetailViewModel(projectId = "project-1") + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() - viewModel.onMoreMenuClick() - viewModel.onDismissMoreMenu() + viewModel.onMoreMenuClick() + viewModel.onDismissMoreMenu() - assertFalse(viewModel.uiState.value.showMoreMenu) + assertFalse(viewModel.uiState.value.showMoreMenu) + } } /** 삭제 확인 시 홈 이동 이벤트를 발행하는지 검증한다. */ @Test - fun onDeleteProjectClick_emitsNavigateToHome() = - runBlocking { - val viewModel = ProjectDetailViewModel(projectId = "project-1") + fun onDeleteProjectClick_emitsNavigateToHome() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() val event = async(start = CoroutineStart.UNDISPATCHED) { viewModel.events.first() } viewModel.onDeleteProjectClick() assertEquals(ProjectDetailEvent.NavigateToHome, event.await()) } + } /** 프로젝트 상세의 플레이 버튼이 문제 풀이 이동 이벤트를 발행하는지 검증한다. */ @Test - fun onQuestionSolvingClick_emitsNavigateToQuiz() = - runBlocking { - val viewModel = ProjectDetailViewModel(projectId = "project-1") + fun onQuestionSolvingClick_emitsNavigateToQuiz() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() val event = async(start = CoroutineStart.UNDISPATCHED) { viewModel.events.first() } viewModel.onQuestionSolvingClick() assertEquals(ProjectDetailEvent.NavigateToQuiz("project-1"), event.await()) } + } + + /** 스타 수 축약 표기의 경계 값을 검증한다. */ + @Test + fun formatStarCount_경계값을_k표기로변환한다() { + assertEquals("999", formatStarCount(999)) + assertEquals("1k", formatStarCount(1_000)) + assertEquals("3.6k", formatStarCount(3_649)) + assertEquals("12.3k", formatStarCount(12_345)) + } + + private fun createViewModel(): ProjectDetailViewModel = + ProjectDetailViewModel( + projectId = "project-1", + getProjectDetail = GetProjectDetailUseCase(FakeProjectDetailRepository(Result.success(DETAIL))), + ) + + private companion object { + private val DETAIL = + ProjectDetail( + projectId = "project-1", + repositoryUrl = "https://github.com/facebook/react", + repositoryName = "react", + repositoryImageUrl = "https://example.com/a.png", + starCount = 3_649, + techStack = listOf("TypeScript", "JavaScript"), + overallProgressPercent = 60, + nextProblemId = "q1", + sets = + listOf( + LearningSetSummary( + setId = "s1", + label = "Set 1", + title = "라우팅 흐름 따라가기", + problemCount = 5, + completedCount = 3, + ), + ), + ) + } } diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index d0d74f6e..f0670d9f 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -7,6 +7,7 @@ import com.nexters.hytime.gitit.domain.repository.GitHubRepositoryRepository import com.nexters.hytime.gitit.domain.repository.MemberRepository import com.nexters.hytime.gitit.domain.repository.ProjectRepository import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import com.nexters.hytime.gitit.domain.usecase.LoadGitHubRepositoryUseCase import com.nexters.hytime.gitit.domain.usecase.RegisterProjectUseCase @@ -44,12 +45,13 @@ val appModule: Module = single { LoadGitHubRepositoryUseCase(repository = get()) } single { GetMemberProfileUseCase(repository = get()) } single { GetProjectsUseCase(repository = get()) } + single { GetProjectDetailUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } viewModel { HomeViewModel(getProjects = get()) } viewModel { ProjectListViewModel(getProjects = get()) } - viewModel { params -> ProjectDetailViewModel(projectId = params.get()) } + viewModel { params -> ProjectDetailViewModel(projectId = params.get(), getProjectDetail = get()) } viewModel { ProjectLoadViewModel(loadGitHubRepository = get()) } single { QuizCreateStore() } single { QuizCreateRetryHandler(registerProject = get(), createStore = get()) } From f5acee6c9a1a25b2072c5f7ee3ed1cd3b6b8b734 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 14:44:44 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=82=AD=EC=A0=9C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DELETE /api/v1/projects/{projectId}를 DeleteProjectUseCase로 노출한다 - 리스트 삭제 모달 확정 시 로컬 필터 대신 서버 삭제를 호출하고, 성공했을 때만 목록에서 제거한다 - 상세 더보기 삭제도 서버 삭제 성공 시에만 홈으로 이동한다 - 실패 시 목록·화면을 유지하고 로그만 남긴다. 실패 안내 UI는 디자인에 없어 추가하지 않았다 - ktlintCheck, :feature:projectlist:jvmTest, :feature:projectdetail:jvmTest, :domain:test, :shared:jvmTest 통과 --- .../domain/usecase/DeleteProjectUseCase.kt | 20 +++++++++++ .../projectdetail/ProjectDetailViewModel.kt | 15 ++++++-- .../FakeProjectDetailRepository.kt | 10 +++++- .../ProjectDetailViewModelTest.kt | 29 ++++++++++----- .../projectlist/ProjectListViewModel.kt | 35 +++++++++++++----- .../projectlist/FakeProjectListRepository.kt | 10 +++++- .../projectlist/ProjectListViewModelTest.kt | 36 ++++++++++++++++--- .../kotlin/com/nexters/hytime/gitit/Di.kt | 8 +++-- 8 files changed, 136 insertions(+), 27 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/DeleteProjectUseCase.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/DeleteProjectUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/DeleteProjectUseCase.kt new file mode 100644 index 00000000..815d4ff1 --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/DeleteProjectUseCase.kt @@ -0,0 +1,20 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 등록한 프로젝트를 삭제한다. + * + * @property repository 프로젝트 정보를 제공하는 도메인 계약 + */ +class DeleteProjectUseCase( + private val repository: ProjectRepository, +) { + /** + * 프로젝트를 서버에서 삭제한다. + * + * @param projectId 삭제할 프로젝트 식별자 + * @return 삭제 결과 또는 실패 원인 + */ + suspend operator fun invoke(projectId: String): Result = repository.deleteProject(projectId) +} diff --git a/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModel.kt b/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModel.kt index c9fde0be..01057133 100644 --- a/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModel.kt +++ b/feature/projectdetail/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModel.kt @@ -2,6 +2,7 @@ package com.nexters.hytime.gitit.feature.projectdetail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.DeleteProjectUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow @@ -20,10 +21,12 @@ import kotlinx.coroutines.launch * * @property projectId 네비게이션 인자로 전달된 프로젝트 식별자 * @property getProjectDetail 프로젝트 상세 정보를 조회하는 유스케이스 + * @property deleteProject 프로젝트를 서버에서 삭제하는 유스케이스 */ class ProjectDetailViewModel( val projectId: String, private val getProjectDetail: GetProjectDetailUseCase, + private val deleteProject: DeleteProjectUseCase, ) : ViewModel() { private val logger = gitItLogger() @@ -73,10 +76,16 @@ class ProjectDetailViewModel( emit(ProjectDetailEvent.NavigateToSavedQuestions) } - /** 프로젝트 삭제를 확정하고 홈 화면 이동 이벤트를 흘려보낸다. */ + /** + * 프로젝트를 서버에서 삭제하고 성공하면 홈 화면 이동 이벤트를 흘려보낸다. + * 실패하면 화면을 유지하고 원인을 로그로 남긴다. + */ fun onDeleteProjectClick() { - // TODO: 삭제 유스케이스 연동 후 홈 이동 전에 프로젝트를 삭제한다. - emit(ProjectDetailEvent.NavigateToHome) + viewModelScope.launch { + deleteProject(projectId) + .onSuccess { emit(ProjectDetailEvent.NavigateToHome) } + .onFailure { error -> logger.e(throwable = error) { "프로젝트 삭제 실패" } } + } } /** diff --git a/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt index b5e28230..2c3691fb 100644 --- a/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt +++ b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt @@ -14,10 +14,15 @@ import com.nexters.hytime.gitit.domain.repository.ProjectRepository * 상세 조회에 지정한 결과만 돌려주는 테스트용 프로젝트 리포지토리다. * * @property detailResult 상세 조회에 돌려줄 결과 + * @property deleteResult 삭제 요청에 돌려줄 결과 */ internal class FakeProjectDetailRepository( private val detailResult: Result, + private val deleteResult: Result = Result.success(Unit), ) : ProjectRepository { + /** 마지막으로 삭제 요청한 프로젝트 식별자다. */ + var deletedProjectId: String? = null + override suspend fun registerProject( githubRepoUrl: String, quizLevel: ProjectQuizLevel, @@ -30,7 +35,10 @@ internal class FakeProjectDetailRepository( override suspend fun getProjectDetail(projectId: String): Result = detailResult - override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + override suspend fun deleteProject(projectId: String): Result { + deletedProjectId = projectId + return deleteResult + } override suspend fun getLearningSet( projectId: String, diff --git a/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModelTest.kt b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModelTest.kt index e4b957a9..642adb51 100644 --- a/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModelTest.kt +++ b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/ProjectDetailViewModelTest.kt @@ -4,6 +4,7 @@ package com.nexters.hytime.gitit.feature.projectdetail import com.nexters.hytime.gitit.domain.model.LearningSetSummary import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.usecase.DeleteProjectUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers @@ -64,11 +65,12 @@ class ProjectDetailViewModelTest { @Test fun init_상세조회에실패하면_로딩상태를유지한다() { runTest(dispatcher) { + val repository = FakeProjectDetailRepository(Result.failure(IllegalStateException("오류"))) val viewModel = ProjectDetailViewModel( projectId = "project-1", - getProjectDetail = - GetProjectDetailUseCase(FakeProjectDetailRepository(Result.failure(IllegalStateException("오류")))), + getProjectDetail = GetProjectDetailUseCase(repository), + deleteProject = DeleteProjectUseCase(repository), ) runCurrent() @@ -105,16 +107,24 @@ class ProjectDetailViewModelTest { } } - /** 삭제 확인 시 홈 이동 이벤트를 발행하는지 검증한다. */ + /** 서버 삭제가 성공해야 홈 이동 이벤트를 발행하는지 검증한다. */ @Test - fun onDeleteProjectClick_emitsNavigateToHome() { + fun onDeleteProjectClick_삭제에성공하면_홈이동이벤트를발행한다() { runTest(dispatcher) { - val viewModel = createViewModel() + val repository = FakeProjectDetailRepository(Result.success(DETAIL)) + val viewModel = + ProjectDetailViewModel( + projectId = "project-1", + getProjectDetail = GetProjectDetailUseCase(repository), + deleteProject = DeleteProjectUseCase(repository), + ) runCurrent() val event = async(start = CoroutineStart.UNDISPATCHED) { viewModel.events.first() } viewModel.onDeleteProjectClick() + runCurrent() + assertEquals("project-1", repository.deletedProjectId) assertEquals(ProjectDetailEvent.NavigateToHome, event.await()) } } @@ -142,11 +152,14 @@ class ProjectDetailViewModelTest { assertEquals("12.3k", formatStarCount(12_345)) } - private fun createViewModel(): ProjectDetailViewModel = - ProjectDetailViewModel( + private fun createViewModel(): ProjectDetailViewModel { + val repository = FakeProjectDetailRepository(Result.success(DETAIL)) + return ProjectDetailViewModel( projectId = "project-1", - getProjectDetail = GetProjectDetailUseCase(FakeProjectDetailRepository(Result.success(DETAIL))), + getProjectDetail = GetProjectDetailUseCase(repository), + deleteProject = DeleteProjectUseCase(repository), ) + } private companion object { private val DETAIL = diff --git a/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModel.kt b/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModel.kt index 31d49d0b..30f48dfc 100644 --- a/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModel.kt +++ b/feature/projectlist/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModel.kt @@ -2,6 +2,7 @@ package com.nexters.hytime.gitit.feature.projectlist import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.DeleteProjectUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow @@ -17,9 +18,11 @@ import kotlinx.coroutines.launch * 프로젝트 리스트 화면의 상태와 사용자 의도를 관리한다. * * @property getProjects 등록한 프로젝트 목록을 조회하는 유스케이스 + * @property deleteProject 프로젝트를 서버에서 삭제하는 유스케이스 */ class ProjectListViewModel( private val getProjects: GetProjectsUseCase, + private val deleteProject: DeleteProjectUseCase, ) : ViewModel() { private val logger = gitItLogger() @@ -58,14 +61,7 @@ class ProjectListViewModel( setState { copy(pendingDeleteProjectId = intent.projectId) } } ProjectListIntent.ConfirmDeleteClick -> - _uiState.value.pendingDeleteProjectId?.let { projectId -> - setState { - copy( - projects = projects.filterNot { it.id == projectId }, - pendingDeleteProjectId = null, - ) - } - } + _uiState.value.pendingDeleteProjectId?.let(::deleteProjectOnServer) ProjectListIntent.DismissDeleteClick -> setState { copy(pendingDeleteProjectId = null) } is ProjectListIntent.ProjectClick -> emit(ProjectListSideEffect.NavigateToProjectDetail(intent.projectId)) is ProjectListIntent.PlayProjectClick -> emit(ProjectListSideEffect.NavigateToQuiz(intent.projectId)) @@ -85,6 +81,29 @@ class ProjectListViewModel( _sideEffects.tryEmit(sideEffect) } + /** + * 서버에서 프로젝트를 삭제하고 성공하면 목록에서도 제거한다. + * 실패하면 목록을 유지하고 원인을 로그로 남긴다. + * + * @param projectId 삭제할 프로젝트 식별자 + */ + private fun deleteProjectOnServer(projectId: String) { + viewModelScope.launch { + deleteProject(projectId) + .onSuccess { + setState { + copy( + projects = projects.filterNot { it.id == projectId }, + pendingDeleteProjectId = null, + ) + } + }.onFailure { error -> + setState { copy(pendingDeleteProjectId = null) } + logger.e(throwable = error) { "프로젝트 삭제 실패" } + } + } + } + /** * 프로젝트 목록을 조회해 화면 상태를 채운다. * 실패하면 빈 목록을 유지하고 원인을 로그로 남긴다. diff --git a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt index ab3c4aa4..bd3cdd41 100644 --- a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt +++ b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt @@ -14,10 +14,15 @@ import com.nexters.hytime.gitit.domain.repository.ProjectRepository * 목록 조회에 지정한 결과만 돌려주는 테스트용 프로젝트 리포지토리다. * * @property pageResult 목록 조회에 돌려줄 결과 + * @property deleteResult 삭제 요청에 돌려줄 결과 */ internal class FakeProjectListRepository( private val pageResult: Result, + private val deleteResult: Result = Result.success(Unit), ) : ProjectRepository { + /** 마지막으로 삭제 요청한 프로젝트 식별자다. */ + var deletedProjectId: String? = null + override suspend fun registerProject( githubRepoUrl: String, quizLevel: ProjectQuizLevel, @@ -30,7 +35,10 @@ internal class FakeProjectListRepository( override suspend fun getProjectDetail(projectId: String): Result = error("호출되면 안 됩니다.") - override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + override suspend fun deleteProject(projectId: String): Result { + deletedProjectId = projectId + return deleteResult + } override suspend fun getLearningSet( projectId: String, diff --git a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt index 3c495d7d..11d36f5b 100644 --- a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt +++ b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt @@ -4,6 +4,7 @@ package com.nexters.hytime.gitit.feature.projectlist import com.nexters.hytime.gitit.domain.model.ProjectPage import com.nexters.hytime.gitit.domain.model.ProjectSummary +import com.nexters.hytime.gitit.domain.usecase.DeleteProjectUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers @@ -58,9 +59,11 @@ class ProjectListViewModelTest { @Test fun init_목록조회에실패하면_빈목록을유지한다() { runTest(dispatcher) { + val repository = FakeProjectListRepository(Result.failure(IllegalStateException("오류"))) val viewModel = ProjectListViewModel( - getProjects = GetProjectsUseCase(FakeProjectListRepository(Result.failure(IllegalStateException("오류")))), + getProjects = GetProjectsUseCase(repository), + deleteProject = DeleteProjectUseCase(repository), ) runCurrent() @@ -160,10 +163,35 @@ class ProjectListViewModelTest { } } - private fun createViewModel(): ProjectListViewModel = - ProjectListViewModel( - getProjects = GetProjectsUseCase(FakeProjectListRepository(Result.success(PAGE))), + /** 삭제 요청이 실패하면 목록을 유지하고 모달만 닫는다. */ + @Test + fun confirmDeleteClick_삭제요청이실패하면_목록을유지한다() { + runTest(dispatcher) { + val repository = FakeProjectListRepository(Result.success(PAGE), Result.failure(IllegalStateException("오류"))) + val viewModel = + ProjectListViewModel( + getProjects = GetProjectsUseCase(repository), + deleteProject = DeleteProjectUseCase(repository), + ) + runCurrent() + + viewModel.onIntent(ProjectListIntent.DeleteProjectClick("p1")) + viewModel.onIntent(ProjectListIntent.ConfirmDeleteClick) + runCurrent() + + assertEquals("p1", repository.deletedProjectId) + assertEquals(3, viewModel.uiState.value.projects.size) + assertEquals(null, viewModel.uiState.value.pendingDeleteProjectId) + } + } + + private fun createViewModel(): ProjectListViewModel { + val repository = FakeProjectListRepository(Result.success(PAGE)) + return ProjectListViewModel( + getProjects = GetProjectsUseCase(repository), + deleteProject = DeleteProjectUseCase(repository), ) + } private companion object { private val PAGE = diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index f0670d9f..83c012fb 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -6,6 +6,7 @@ import com.nexters.hytime.gitit.domain.repository.AuthRepository import com.nexters.hytime.gitit.domain.repository.GitHubRepositoryRepository import com.nexters.hytime.gitit.domain.repository.MemberRepository import com.nexters.hytime.gitit.domain.repository.ProjectRepository +import com.nexters.hytime.gitit.domain.usecase.DeleteProjectUseCase import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase @@ -46,12 +47,15 @@ val appModule: Module = single { GetMemberProfileUseCase(repository = get()) } single { GetProjectsUseCase(repository = get()) } single { GetProjectDetailUseCase(repository = get()) } + single { DeleteProjectUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } viewModel { HomeViewModel(getProjects = get()) } - viewModel { ProjectListViewModel(getProjects = get()) } - viewModel { params -> ProjectDetailViewModel(projectId = params.get(), getProjectDetail = get()) } + viewModel { ProjectListViewModel(getProjects = get(), deleteProject = get()) } + viewModel { params -> + ProjectDetailViewModel(projectId = params.get(), getProjectDetail = get(), deleteProject = get()) + } viewModel { ProjectLoadViewModel(loadGitHubRepository = get()) } single { QuizCreateStore() } single { QuizCreateRetryHandler(registerProject = get(), createStore = get()) } From 7050dc4028b53fb9beb5e0ff92f5102f8dc9aa43 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 14:52:56 +0900 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20=EB=AC=B8=EC=A0=9C=20=ED=92=80?= =?UTF-8?q?=EC=9D=B4=EC=97=90=20=ED=95=99=EC=8A=B5=20=EC=84=B8=ED=8A=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/projects/{projectId}/sets/{setId}를 GetLearningSetUseCase로 노출한다 - 고정 2문제 샘플 흐름을 세트의 문제 목록을 순서대로 진행하는 구조로 일반화했다 기존 객관식·서술형 화면 컴포넌트를 그대로 재사용하고 새 UI는 만들지 않았다 - 세트 식별자가 없으면 프로젝트 상세로 아직 다 풀지 않은 첫 세트를 골라 이어 푼다 세트 라벨과 출처 저장소 이름도 상세에서 가져온다 - 클라이언트가 모르는 문제 형식은 표시할 화면이 없어 걸러낸다 - 정답·해설은 제출 응답에만 있으므로 채점 반영은 다음 커밋의 제출 연동에서 다룬다 - main에서 온 quiz create 테스트 페이크가 커진 ProjectRepository를 구현하지 않아 컴파일이 깨져 있던 것을 함께 고쳤다 - ktlintCheck, :feature:quiz:jvmTest, :domain:test, :shared:jvmTest, :androidApp:compileDebugKotlin 통과 --- .../domain/usecase/GetLearningSetUseCase.kt | 25 ++ feature/quiz/build.gradle.kts | 1 + .../feature/quiz/solve/SolveQuizContract.kt | 153 ++++---- .../feature/quiz/solve/SolveQuizMapper.kt | 109 ++++++ .../feature/quiz/solve/SolveQuizRoute.kt | 6 +- .../feature/quiz/solve/SolveQuizScreen.kt | 66 +++- .../feature/quiz/solve/SolveQuizViewModel.kt | 139 +++++++- .../quiz/create/QuizCreateViewModelTest.kt | 40 +++ .../session/QuizCreateRetryHandlerTest.kt | 40 +++ .../quiz/solve/FakeSolveQuizRepository.kt | 67 ++++ .../quiz/solve/SolveQuizViewModelTest.kt | 329 +++++++++++++----- .../kotlin/com/nexters/hytime/gitit/Di.kt | 7 + 12 files changed, 805 insertions(+), 177 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetLearningSetUseCase.kt create mode 100644 feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizMapper.kt create mode 100644 feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetLearningSetUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetLearningSetUseCase.kt new file mode 100644 index 00000000..b6dc568e --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetLearningSetUseCase.kt @@ -0,0 +1,25 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 문제 풀이에 필요한 학습 세트를 조회한다. + * + * @property repository 프로젝트 정보를 제공하는 도메인 계약 + */ +class GetLearningSetUseCase( + private val repository: ProjectRepository, +) { + /** + * 학습 세트 하나를 문제 목록과 함께 가져온다. + * + * @param projectId 세트가 속한 프로젝트 식별자 + * @param setId 조회할 학습 세트 식별자 + * @return 조회된 학습 세트 또는 실패 원인 + */ + suspend operator fun invoke( + projectId: String, + setId: String, + ): Result = repository.getLearningSet(projectId, setId) +} diff --git a/feature/quiz/build.gradle.kts b/feature/quiz/build.gradle.kts index d91ccbf9..edcfc471 100644 --- a/feature/quiz/build.gradle.kts +++ b/feature/quiz/build.gradle.kts @@ -6,6 +6,7 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core.designsystem) + implementation(projects.core.logging) implementation(projects.domain) implementation(libs.coil.compose) diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizContract.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizContract.kt index 87b85a08..ce5a6481 100644 --- a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizContract.kt +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizContract.kt @@ -8,15 +8,15 @@ package com.nexters.hytime.gitit.feature.quiz.solve * @property description 세트의 학습 범위 설명 */ data class QuizSetInfo( - val label: String, - val title: String, - val description: String, + val label: String = "", + val title: String = "", + val description: String = "", ) /** * 객관식 답안 하나다. * - * @property id 선택과 채점에 사용하는 식별자 + * @property id 선택과 채점에 사용하는 식별자. 선택지 순서를 담은 0부터 시작하는 번호다 * @property label 화면에 표시할 알파벳 기호 * @property text 답안 내용 */ @@ -34,45 +34,84 @@ data class QuizAnswer( * @property url 외부에서 열 원본 코드 주소 */ data class QuizSource( - val description: String, - val label: String, - val url: String, + val description: String = "", + val label: String = "", + val url: String = "", ) /** - * 로컬 샘플 객관식 문제다. + * 객관식 문제다. * + * @property id 답 제출에 사용하는 문제 식별자 * @property number 세트 안에서 표시할 문제 순서 * @property text 질문 본문 * @property answers 사용자가 선택할 답안 목록 - * @property correctAnswerId 정답으로 판정할 답안 식별자 - * @property explanation 채점 후 표시할 해설 + * @property correctAnswerId 정답 답안 식별자. 서버 채점 전에는 null + * @property explanation 채점 후 표시할 해설. 서버 채점 전에는 빈 문자열 * @property source 문제와 연결된 원본 코드 정보 */ data class QuizQuestion( - val number: Int, - val text: String, - val answers: List, - val correctAnswerId: String, - val explanation: String, - val source: QuizSource, + val id: String = "", + val number: Int = 0, + val text: String = "", + val answers: List = emptyList(), + val correctAnswerId: String? = null, + val explanation: String = "", + val source: QuizSource = QuizSource(), ) /** - * 로컬 샘플 서술형 문제다. + * 서술형 문제다. * + * @property id 답 제출에 사용하는 문제 식별자 * @property number 세트 안에서 표시할 문제 순서 * @property text 질문 본문 - * @property modelAnswer 채점 후 비교할 AI 모범 답안 - * @property source 추후 출처 버튼에서 사용할 원본 코드 정보 + * @property modelAnswer 채점 후 비교할 모범 답안. 서버 채점 전에는 빈 문자열 + * @property source 문제와 연결된 원본 코드 정보 */ data class EssayQuestion( - val number: Int, - val text: String, - val modelAnswer: String, - val source: QuizSource, + val id: String = "", + val number: Int = 0, + val text: String = "", + val modelAnswer: String = "", + val source: QuizSource = QuizSource(), ) +/** + * 학습 세트에 담긴 문제 하나를 형식에 따라 담는다. + */ +sealed interface SolveQuizQuestionItem { + /** 답 제출에 사용하는 문제 식별자다. */ + val questionId: String + + /** 세트 안에서 표시할 문제 순서다. */ + val number: Int + + /** + * 객관식 문제 항목이다. + * + * @property question 화면에 표시할 객관식 문제 + */ + data class MultipleChoice( + val question: QuizQuestion, + ) : SolveQuizQuestionItem { + override val questionId: String get() = question.id + override val number: Int get() = question.number + } + + /** + * 서술형 문제 항목이다. + * + * @property question 화면에 표시할 서술형 문제 + */ + data class Essay( + val question: EssayQuestion, + ) : SolveQuizQuestionItem { + override val questionId: String get() = question.id + override val number: Int get() = question.number + } +} + /** 문제 풀이 플로우의 현재 단계를 나타낸다. */ enum class QuizStep { /** 문제 풀이 전 세트 소개 단계다. */ @@ -91,9 +130,11 @@ enum class QuizStep { /** * 문제 풀이 화면의 단일 상태다. * + * 세트의 문제 목록을 순서대로 진행하며, 현재 문제는 [currentIndex]가 가리킨다. + * * @property setInfo 시작 화면에 표시할 세트 정보 - * @property multipleChoiceQuestion 객관식 샘플 문제 - * @property essayQuestion 서술형 샘플 문제 + * @property questions 세트에 담긴 문제 목록. 형식에 따라 객관식·서술형 화면이 표시된다 + * @property currentIndex 현재 풀고 있는 문제의 위치 * @property step 현재 표시할 문제 풀이 단계 * @property selectedAnswerId 채점 전에 선택한 답안 식별자 * @property isMultipleChoiceSubmitted 객관식 정답 확인을 완료했는지 여부 @@ -103,9 +144,9 @@ enum class QuizStep { * @property bookmarkedQuestionNumbers 저장한 문제 번호 집합 */ data class SolveQuizUiState( - val setInfo: QuizSetInfo = sampleQuizSetInfo, - val multipleChoiceQuestion: QuizQuestion = sampleQuizQuestion, - val essayQuestion: EssayQuestion = sampleEssayQuestion, + val setInfo: QuizSetInfo = QuizSetInfo(), + val questions: List = emptyList(), + val currentIndex: Int = 0, val step: QuizStep = QuizStep.Intro, val selectedAnswerId: String? = null, val isMultipleChoiceSubmitted: Boolean = false, @@ -113,7 +154,15 @@ data class SolveQuizUiState( val essayAnswer: String = "", val isEssaySubmitted: Boolean = false, val bookmarkedQuestionNumbers: Set = emptySet(), -) +) { + /** 현재 문제가 객관식일 때 화면에 표시할 문제다. 아니면 빈 문제를 돌려준다. */ + val multipleChoiceQuestion: QuizQuestion + get() = (questions.getOrNull(currentIndex) as? SolveQuizQuestionItem.MultipleChoice)?.question ?: QuizQuestion() + + /** 현재 문제가 서술형일 때 화면에 표시할 문제다. 아니면 빈 문제를 돌려준다. */ + val essayQuestion: EssayQuestion + get() = (questions.getOrNull(currentIndex) as? SolveQuizQuestionItem.Essay)?.question ?: EssayQuestion() +} /** 문제 풀이 화면에서 발생하는 사용자 의도다. */ sealed interface SolveQuizIntent { @@ -169,51 +218,5 @@ sealed interface SolveQuizSideEffect { ) : SolveQuizSideEffect } -/** API 연동 전 화면 동작을 확인하는 Android 입문 세트다. */ -private val sampleQuizSetInfo = - QuizSetInfo( - label = "Set 1", - title = "Android 앱 진입점 확인하기", - description = "Android 앱이 시작되고 Compose UI가 화면에 표시되는 기본 흐름을 실제 코드와 함께 확인하는 학습 세트", - ) - -/** 현재 저장소의 Android 진입점을 출처로 사용하는 쉬운 샘플 문제다. */ -private val sampleQuizQuestion = - QuizQuestion( - number = 1, - text = "MainActivity에서 Compose UI를 화면에 표시하기 위해 호출하는 함수는 무엇일까요?", - answers = - listOf( - QuizAnswer("set-content", "A", "setContent"), - QuizAnswer("set-state", "B", "setState"), - QuizAnswer("set-view", "C", "setView"), - QuizAnswer("render", "D", "render"), - ), - correctAnswerId = "set-content", - explanation = "ComponentActivity의 setContent 블록이 Compose UI 트리를 만들며, 이 프로젝트는 그 안에서 App 컴포저블을 호출합니다.", - source = - QuizSource( - description = "MainActivity.onCreate()에서 setContent { App() }을 호출해 공유 Compose UI를 화면에 설정합니다.", - label = "Git-It-KMP · MainActivity.kt:L12–L18", - url = - "https://github.com/Nexters/Git-It-KMP/blob/main/androidApp/src/main/kotlin/com/nexters/hytime/gitit/MainActivity.kt#L12-L18", - ), - ) - -/** 공유 UI가 Android와 Desktop에서 재사용되는 구조를 설명하는 서술형 샘플 문제다. */ -private val sampleEssayQuestion = - EssayQuestion( - number = 2, - text = "Git-It-KMP가 Android와 Desktop에서 같은 Compose UI를 사용할 수 있는 구조를 설명해 보세요.", - modelAnswer = - "공유 UI와 화면 로직은 shared 모듈의 commonMain에 두고, Android와 Desktop 앱은 각각의 진입점에서 같은 App 컴포저블을 호출합니다. 플랫폼 API가 필요한 부분만 플랫폼별 소스셋으로 분리합니다.", - source = - QuizSource( - description = "Android와 Desktop 진입점은 shared 모듈의 공통 App 컴포저블을 호출해 같은 UI를 표시합니다.", - label = "Git-It-KMP · App.kt", - url = "https://github.com/Nexters/Git-It-KMP/blob/main/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/App.kt", - ), - ) - /** 서술형 답안의 최대 글자 수다. */ const val ESSAY_ANSWER_MAX_LENGTH = 300 diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizMapper.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizMapper.kt new file mode 100644 index 00000000..b3bd39ee --- /dev/null +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizMapper.kt @@ -0,0 +1,109 @@ +package com.nexters.hytime.gitit.feature.quiz.solve + +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.Question +import com.nexters.hytime.gitit.domain.model.QuestionFormat +import com.nexters.hytime.gitit.domain.model.QuestionSource + +/** + * 학습 세트 도메인 모델을 문제 풀이 소개 정보로 변환한다. + * + * @param label 프로젝트 상세가 알려준 세트 라벨 (예: `"Set 1"`) + * @return 시작 화면에 표시할 세트 정보 + */ +internal fun LearningSet.toSetInfo(label: String): QuizSetInfo = + QuizSetInfo( + label = label, + title = title, + description = description, + ) + +/** + * 학습 세트의 문제들을 화면 문제 목록으로 변환한다. + * + * 클라이언트가 모르는 형식의 문제는 표시할 화면이 없으므로 걸러낸다. + * + * @param repositoryName 출처 라벨에 표시할 저장소 이름 + * @return 형식이 확인된 문제 목록. 번호는 걸러낸 뒤의 순서로 다시 매긴다 + */ +internal fun LearningSet.toQuestionItems(repositoryName: String): List { + var number = 0 + return questions.mapNotNull { question -> + when (question.format) { + QuestionFormat.MULTIPLE_CHOICE -> { + number += 1 + SolveQuizQuestionItem.MultipleChoice(question.toQuizQuestion(number, repositoryName)) + } + + QuestionFormat.ESSAY -> { + number += 1 + SolveQuizQuestionItem.Essay(question.toEssayQuestion(number, repositoryName)) + } + + null -> null + } + } +} + +/** + * 도메인 문제를 객관식 화면 모델로 변환한다. + * + * 정답과 해설은 제출 응답에만 담겨 있으므로 여기서는 비워 둔다. + * + * @param number 세트 안에서 표시할 문제 순서 + * @param repositoryName 출처 라벨에 표시할 저장소 이름 + * @return 객관식 화면 모델 + */ +private fun Question.toQuizQuestion( + number: Int, + repositoryName: String, +): QuizQuestion = + QuizQuestion( + id = questionId, + number = number, + text = text, + answers = + choices.mapIndexed { index, choice -> + QuizAnswer( + id = index.toString(), + label = ('A' + index).toString(), + text = choice, + ) + }, + source = sources.firstOrNull().toQuizSource(repositoryName), + ) + +/** + * 도메인 문제를 서술형 화면 모델로 변환한다. + * + * 모범 답안은 제출 응답의 채점 기준에서 오므로 여기서는 비워 둔다. + * + * @param number 세트 안에서 표시할 문제 순서 + * @param repositoryName 출처 라벨에 표시할 저장소 이름 + * @return 서술형 화면 모델 + */ +private fun Question.toEssayQuestion( + number: Int, + repositoryName: String, +): EssayQuestion = + EssayQuestion( + id = questionId, + number = number, + text = text, + source = sources.firstOrNull().toQuizSource(repositoryName), + ) + +/** + * 문제가 인용한 코드 위치를 출처 표시 모델로 변환한다. + * + * @param repositoryName 출처 라벨에 표시할 저장소 이름 + * @return 출처 정보. 인용한 코드가 없으면 빈 모델 + */ +private fun QuestionSource?.toQuizSource(repositoryName: String): QuizSource { + if (this == null) return QuizSource() + return QuizSource( + description = summary.orEmpty(), + label = "$repositoryName · ${file.substringAfterLast('/')}:L$startLine–L$endLine", + url = url, + ) +} diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizRoute.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizRoute.kt index 1ac5d721..b6454988 100644 --- a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizRoute.kt +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizRoute.kt @@ -5,8 +5,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.platform.LocalUriHandler import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest +import org.koin.compose.viewmodel.koinViewModel +import org.koin.core.parameter.parametersOf /** * 문제 풀이 상태와 일회성 이벤트를 화면에 연결한다. @@ -21,7 +22,8 @@ fun SolveQuizRoute( setId: String? = null, onBackClick: () -> Unit, ) { - val viewModel = viewModel(key = "$projectId:$setId") { SolveQuizViewModel(projectId, setId) } + val viewModel = + koinViewModel(key = "$projectId:$setId") { parametersOf(SolveQuizArgs(projectId, setId)) } val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uriHandler = LocalUriHandler.current diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizScreen.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizScreen.kt index df02d0aa..ffb5393e 100644 --- a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizScreen.kt +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizScreen.kt @@ -136,6 +136,7 @@ fun SolveQuizScreen( ) QuizStep.Completed -> QuizCompletionScreen( + questionCount = uiState.questions.size, onCloseClick = { onIntent(SolveQuizIntent.BackClick) }, onNextClick = { onIntent(SolveQuizIntent.BackClick) }, modifier = modifier, @@ -637,12 +638,14 @@ private fun QuizEssayResultCard( /** * 세트의 모든 문제를 푼 뒤 완료 수치와 축하 일러스트를 표시한다. * + * @param questionCount 이번 세트에서 푼 문제 수 * @param onCloseClick 완료 화면을 닫는 콜백 * @param onNextClick 다음 버튼으로 문제 풀이를 종료하는 콜백 * @param modifier 화면의 크기와 배치를 지정할 수식자 */ @Composable private fun QuizCompletionScreen( + questionCount: Int, onCloseClick: () -> Unit, onNextClick: () -> Unit, modifier: Modifier = Modifier, @@ -670,7 +673,7 @@ private fun QuizCompletionScreen( ) Spacer(Modifier.height(40.dp)) Text( - text = stringResource(Res.string.quiz_completion_count, 2, 2), + text = stringResource(Res.string.quiz_completion_count, questionCount, questionCount), color = GitItTheme.colors.blue200, style = GitItTheme.typography.subtitle1, ) @@ -971,7 +974,7 @@ private fun Modifier.captureSky(sky: Sky?): Modifier = if (sky == null) this els @Composable private fun QuizIntroScreenPreview() { GitItTheme { - SolveQuizScreen(uiState = SolveQuizUiState(), onIntent = {}) + SolveQuizScreen(uiState = previewSolveQuizUiState, onIntent = {}) } } @@ -981,11 +984,11 @@ private fun QuizResultScreenPreview() { GitItTheme { SolveQuizScreen( uiState = - SolveQuizUiState( + previewSolveQuizUiState.copy( step = QuizStep.MultipleChoice, - selectedAnswerId = "render", + selectedAnswerId = "3", isMultipleChoiceSubmitted = true, - expandedAnswerIds = setOf("render", "set-content"), + expandedAnswerIds = setOf("3", "0"), ), onIntent = {}, ) @@ -998,7 +1001,8 @@ private fun QuizEssayScreenPreview() { GitItTheme { SolveQuizScreen( uiState = - SolveQuizUiState( + previewSolveQuizUiState.copy( + currentIndex = 1, step = QuizStep.Essay, essayAnswer = "공통 UI는 commonMain에서 공유하고 플랫폼별 구현만 소스셋으로 분리합니다.", ), @@ -1006,3 +1010,53 @@ private fun QuizEssayScreenPreview() { ) } } + +/** 프리뷰에서 사용하는 고정 세트·문제 fixture다. */ +private val previewSolveQuizUiState = + SolveQuizUiState( + setInfo = + QuizSetInfo( + label = "Set 1", + title = "Android 앱 진입점 확인하기", + description = "Android 앱이 시작되고 Compose UI가 화면에 표시되는 기본 흐름을 실제 코드와 함께 확인하는 학습 세트", + ), + questions = + listOf( + SolveQuizQuestionItem.MultipleChoice( + QuizQuestion( + id = "q1", + number = 1, + text = "MainActivity에서 Compose UI를 화면에 표시하기 위해 호출하는 함수는 무엇일까요?", + answers = + listOf( + QuizAnswer("0", "A", "setContent"), + QuizAnswer("1", "B", "setState"), + QuizAnswer("2", "C", "setView"), + QuizAnswer("3", "D", "render"), + ), + correctAnswerId = "0", + explanation = "ComponentActivity의 setContent 블록이 Compose UI 트리를 만들며, 이 프로젝트는 그 안에서 App 컴포저블을 호출합니다.", + source = + QuizSource( + description = "MainActivity.onCreate()에서 setContent { App() }을 호출해 공유 Compose UI를 화면에 설정합니다.", + label = "Git-It-KMP · MainActivity.kt:L12–L18", + url = "https://github.com/Nexters/Git-It-KMP", + ), + ), + ), + SolveQuizQuestionItem.Essay( + EssayQuestion( + id = "q2", + number = 2, + text = "Git-It-KMP가 Android와 Desktop에서 같은 Compose UI를 사용할 수 있는 구조를 설명해 보세요.", + modelAnswer = "공유 UI와 화면 로직은 shared 모듈의 commonMain에 두고, 플랫폼 API가 필요한 부분만 플랫폼별 소스셋으로 분리합니다.", + source = + QuizSource( + description = "Android와 Desktop 진입점은 shared 모듈의 공통 App 컴포저블을 호출해 같은 UI를 표시합니다.", + label = "Git-It-KMP · App.kt", + url = "https://github.com/Nexters/Git-It-KMP", + ), + ), + ), + ), + ) diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt index 55b2fc79..5cd6d98b 100644 --- a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt @@ -1,23 +1,44 @@ package com.nexters.hytime.gitit.feature.quiz.solve import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.model.LearningSetSummary +import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase +import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase +import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** - * 로컬 샘플 문제의 상태와 사용자 의도를 관리한다. + * 문제 풀이 화면 진입에 필요한 인자다. * - * @property projectId 추후 문제 조회에 사용할 프로젝트 식별자 - * @property setId 추후 특정 학습 세트 조회에 사용할 선택적 식별자 + * @property projectId 문제를 불러올 프로젝트 식별자 + * @property setId 특정 학습 세트로 제한할 때 사용하는 선택적 식별자 */ -class SolveQuizViewModel( +data class SolveQuizArgs( val projectId: String, val setId: String? = null, +) + +/** + * 학습 세트의 문제들을 순서대로 풀어 나가는 상태와 사용자 의도를 관리한다. + * + * @property args 프로젝트·세트 식별자 + * @property getProjectDetail 세트 라벨과 이어 풀 세트를 정하기 위한 프로젝트 상세 조회 유스케이스 + * @property getLearningSet 문제 목록을 조회하는 유스케이스 + */ +class SolveQuizViewModel( + private val args: SolveQuizArgs, + private val getProjectDetail: GetProjectDetailUseCase, + private val getLearningSet: GetLearningSetUseCase, ) : ViewModel() { + private val logger = gitItLogger() + private val _uiState = MutableStateFlow(SolveQuizUiState()) /** 문제 풀이 화면이 구독할 현재 상태다. */ @@ -28,6 +49,10 @@ class SolveQuizViewModel( /** 화면 이동과 외부 URL 열기를 전달하는 이벤트 스트림이다. */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadLearningSet() + } + /** * 문제 풀이 화면에서 발생한 사용자 의도를 처리한다. * @@ -35,20 +60,58 @@ class SolveQuizViewModel( */ fun onIntent(intent: SolveQuizIntent) { when (intent) { - SolveQuizIntent.Start -> setState { copy(step = QuizStep.MultipleChoice) } + SolveQuizIntent.Start -> startSolving() SolveQuizIntent.BackClick -> { - setState { SolveQuizUiState() } + setState { copy(step = QuizStep.Intro, currentIndex = 0).clearQuestionState() } _sideEffects.tryEmit(SolveQuizSideEffect.NavigateBack) } SolveQuizIntent.Submit -> submitAnswer() - SolveQuizIntent.Next -> moveToNextStep() + SolveQuizIntent.Next -> moveToNextQuestion() SolveQuizIntent.BookmarkClick -> toggleBookmark() - SolveQuizIntent.OpenSource -> _sideEffects.tryEmit(SolveQuizSideEffect.OpenUrl(uiState.value.currentSource().url)) + SolveQuizIntent.OpenSource -> openSource() is SolveQuizIntent.AnswerClick -> onAnswerClick(intent.answerId) is SolveQuizIntent.EssayAnswerChange -> updateEssayAnswer(intent.answer) } } + /** + * 프로젝트 상세로 풀 세트를 정하고 학습 세트를 조회해 문제 목록을 채운다. + * + * 세트 식별자가 없으면 아직 다 풀지 않은 첫 세트를, 모두 풀었다면 첫 세트를 고른다. + * 실패하면 빈 상태를 유지하고 원인을 로그로 남긴다. + */ + private fun loadLearningSet() { + viewModelScope.launch { + getProjectDetail(args.projectId) + .onSuccess { detail -> + val target = detail.sets.targetSummary(args.setId) + if (target == null) { + logger.e { "풀 수 있는 학습 세트가 없습니다: projectId=${args.projectId}, setId=${args.setId}" } + return@onSuccess + } + getLearningSet(args.projectId, target.setId) + .onSuccess { learningSet -> + setState { + copy( + setInfo = learningSet.toSetInfo(target.label), + questions = learningSet.toQuestionItems(detail.repositoryName), + ) + } + }.onFailure { error -> logger.e(throwable = error) { "학습 세트 조회 실패" } } + }.onFailure { error -> logger.e(throwable = error) { "프로젝트 상세 조회 실패" } } + } + } + + /** 첫 문제 형식에 맞는 단계로 이동해 풀이를 시작한다. 문제가 없으면 완료 화면을 표시한다. */ + private fun startSolving() { + setState { + copy( + currentIndex = 0, + step = questions.firstOrNull()?.toStep() ?: QuizStep.Completed, + ).clearQuestionState() + } + } + private fun onAnswerClick(answerId: String) { if (uiState.value.multipleChoiceQuestion.answers .none { it.id == answerId } @@ -87,20 +150,30 @@ class SolveQuizViewModel( val selectedAnswerId = state.selectedAnswerId ?: return if (state.isMultipleChoiceSubmitted) return + // TODO: 답변 제출 API 연동 후 서버 채점 결과(정답·해설)를 반영한다. setState { copy( isMultipleChoiceSubmitted = true, - expandedAnswerIds = setOf(selectedAnswerId, multipleChoiceQuestion.correctAnswerId), + expandedAnswerIds = setOf(selectedAnswerId), ) } } - private fun moveToNextStep() { + /** 현재 문제를 마쳤으면 다음 문제로 이동하고, 마지막 문제였다면 완료 화면을 표시한다. */ + private fun moveToNextQuestion() { + val state = uiState.value + val finished = + (state.step == QuizStep.MultipleChoice && state.isMultipleChoiceSubmitted) || + (state.step == QuizStep.Essay && state.isEssaySubmitted) + if (!finished) return + setState { - when { - step == QuizStep.MultipleChoice && isMultipleChoiceSubmitted -> copy(step = QuizStep.Essay) - step == QuizStep.Essay && isEssaySubmitted -> copy(step = QuizStep.Completed) - else -> this + val nextIndex = currentIndex + 1 + val next = questions.getOrNull(nextIndex) + if (next == null) { + copy(step = QuizStep.Completed).clearQuestionState() + } else { + copy(currentIndex = nextIndex, step = next.toStep()).clearQuestionState() } } } @@ -124,11 +197,49 @@ class SolveQuizViewModel( } } + /** 출처 URL이 있을 때만 외부 열기 이벤트를 발행한다. */ + private fun openSource() { + val url = uiState.value.currentSource().url + if (url.isNotBlank()) { + _sideEffects.tryEmit(SolveQuizSideEffect.OpenUrl(url)) + } + } + private fun setState(reducer: SolveQuizUiState.() -> SolveQuizUiState) { _uiState.value = _uiState.value.reducer() } } +/** + * 이어서 풀 세트 요약을 고른다. + * + * @param setId 화면 진입 시 지정한 세트 식별자. 없으면 진행 상황으로 고른다 + * @return 지정한 세트, 없으면 다 풀지 않은 첫 세트, 그것도 없으면 첫 세트 + */ +private fun List.targetSummary(setId: String?): LearningSetSummary? = + if (setId != null) { + firstOrNull { it.setId == setId } + } else { + firstOrNull { it.completedCount < it.problemCount } ?: firstOrNull() + } + +/** 문제 형식에 맞는 풀이 단계를 반환한다. */ +private fun SolveQuizQuestionItem.toStep(): QuizStep = + when (this) { + is SolveQuizQuestionItem.MultipleChoice -> QuizStep.MultipleChoice + is SolveQuizQuestionItem.Essay -> QuizStep.Essay + } + +/** 문제 하나에만 유효한 선택·제출·펼침 상태를 초기화한다. */ +private fun SolveQuizUiState.clearQuestionState(): SolveQuizUiState = + copy( + selectedAnswerId = null, + isMultipleChoiceSubmitted = false, + expandedAnswerIds = emptySet(), + essayAnswer = "", + isEssaySubmitted = false, + ) + /** 현재 단계의 문제 번호를 반환한다. */ private fun SolveQuizUiState.currentQuestionNumber(): Int = if (step == diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/create/QuizCreateViewModelTest.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/create/QuizCreateViewModelTest.kt index 2e9876b8..2c119b17 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/create/QuizCreateViewModelTest.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/create/QuizCreateViewModelTest.kt @@ -1,6 +1,12 @@ package com.nexters.hytime.gitit.feature.quiz.create +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.ProjectDetail import com.nexters.hytime.gitit.domain.model.ProjectGenerationStatus +import com.nexters.hytime.gitit.domain.model.ProjectPage import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel import com.nexters.hytime.gitit.domain.model.ProjectRegistration import com.nexters.hytime.gitit.domain.repository.ProjectRepository @@ -330,4 +336,38 @@ private class RecordingProjectRepository( this.quizLevel = quizLevel return result } + + override suspend fun getProjects( + page: Int, + size: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjectDetail(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun getLearningSet( + projectId: String, + setId: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitChoiceAnswer( + projectId: String, + questionId: String, + selectedIndex: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitEssayAnswer( + projectId: String, + questionId: String, + text: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun bookmarkQuestion( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getBookmarkedQuestions(projectId: String?): Result = error("호출되면 안 됩니다.") } diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/create/session/QuizCreateRetryHandlerTest.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/create/session/QuizCreateRetryHandlerTest.kt index 90493612..419d74f4 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/create/session/QuizCreateRetryHandlerTest.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/create/session/QuizCreateRetryHandlerTest.kt @@ -2,7 +2,13 @@ package com.nexters.hytime.gitit.feature.quiz.create.session +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.ProjectDetail import com.nexters.hytime.gitit.domain.model.ProjectGenerationStatus +import com.nexters.hytime.gitit.domain.model.ProjectPage import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel import com.nexters.hytime.gitit.domain.model.ProjectRegistration import com.nexters.hytime.gitit.domain.repository.ProjectRepository @@ -83,4 +89,38 @@ private class RetryProjectRepository( this.quizLevel = quizLevel return result } + + override suspend fun getProjects( + page: Int, + size: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjectDetail(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun getLearningSet( + projectId: String, + setId: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitChoiceAnswer( + projectId: String, + questionId: String, + selectedIndex: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitEssayAnswer( + projectId: String, + questionId: String, + text: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun bookmarkQuestion( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getBookmarkedQuestions(projectId: String?): Result = error("호출되면 안 됩니다.") } diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt new file mode 100644 index 00000000..2578944d --- /dev/null +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt @@ -0,0 +1,67 @@ +package com.nexters.hytime.gitit.feature.quiz.solve + +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel +import com.nexters.hytime.gitit.domain.model.ProjectRegistration +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 문제 풀이 테스트에 필요한 조회만 지원하는 테스트용 프로젝트 리포지토리다. + * + * @property detailResult 상세 조회에 돌려줄 결과 + * @property learningSetResult 학습 세트 조회에 돌려줄 결과 + */ +internal class FakeSolveQuizRepository( + private val detailResult: Result, + private val learningSetResult: Result, +) : ProjectRepository { + /** 마지막으로 학습 세트를 조회한 세트 식별자다. */ + var requestedSetId: String? = null + + override suspend fun registerProject( + githubRepoUrl: String, + quizLevel: ProjectQuizLevel, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjects( + page: Int, + size: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjectDetail(projectId: String): Result = detailResult + + override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun getLearningSet( + projectId: String, + setId: String, + ): Result { + requestedSetId = setId + return learningSetResult + } + + override suspend fun submitChoiceAnswer( + projectId: String, + questionId: String, + selectedIndex: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitEssayAnswer( + projectId: String, + questionId: String, + text: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun bookmarkQuestion( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getBookmarkedQuestions(projectId: String?): Result = error("호출되면 안 됩니다.") +} diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt index 805c85fb..c1884793 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt @@ -1,141 +1,310 @@ +@file:Suppress("ktlint:standard:function-naming") + package com.nexters.hytime.gitit.feature.quiz.solve import com.nexters.hytime.gitit.designsystem.quiz.GitItMultipleChoiceAnswerState +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.LearningSetSummary +import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel +import com.nexters.hytime.gitit.domain.model.Question +import com.nexters.hytime.gitit.domain.model.QuestionFormat +import com.nexters.hytime.gitit.domain.model.QuestionSource +import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase +import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -/** 문제 풀이 ViewModel의 핵심 상태 전환을 검증한다. */ +/** 문제 풀이 ViewModel의 세트 조회와 문제 순회를 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) class SolveQuizViewModelTest { - /** 시작·선택·정답 제출이 결과 화면에 필요한 상태를 만든다. */ + private val dispatcher = StandardTestDispatcher() + + /** ViewModel의 Main dispatcher를 테스트 dispatcher로 교체한다. */ + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + /** 테스트 이후 Main dispatcher를 복원한다. */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** 세트를 조회하면 소개 정보와 문제 목록이 형식 순서대로 채워진다. */ @Test - fun onIntent_correctAnswer_expandsOnlyCorrectAnswer() { - val viewModel = createViewModel() + fun init_세트조회에성공하면_문제목록을순서대로채운다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + + val state = viewModel.uiState.value + assertEquals("Set 1", state.setInfo.label) + assertEquals("라우팅 흐름 따라가기", state.setInfo.title) + assertEquals(3, state.questions.size) + assertTrue(state.questions[0] is SolveQuizQuestionItem.MultipleChoice) + assertTrue(state.questions[1] is SolveQuizQuestionItem.Essay) + assertTrue(state.questions[2] is SolveQuizQuestionItem.MultipleChoice) + assertEquals(listOf(1, 2, 3), state.questions.map(SolveQuizQuestionItem::number)) + assertEquals(QuizStep.Intro, state.step) + } + } - viewModel.onIntent(SolveQuizIntent.Start) - viewModel.onIntent(SolveQuizIntent.AnswerClick("set-content")) - viewModel.onIntent(SolveQuizIntent.Submit) + /** 세트 식별자를 지정하지 않으면 다 풀지 않은 첫 세트를 고른다. */ + @Test + fun init_세트식별자가없으면_덜푼첫세트를고른다() { + runTest(dispatcher) { + val repository = FakeSolveQuizRepository(Result.success(DETAIL), Result.success(LEARNING_SET)) + createViewModel(repository = repository, setId = null) + runCurrent() - val state = viewModel.uiState.value - assertEquals(QuizStep.MultipleChoice, state.step) - assertTrue(state.isMultipleChoiceSubmitted) - assertEquals(setOf("set-content"), state.expandedAnswerIds) + assertEquals("s2", repository.requestedSetId) + } } - /** 오답 제출 시 선택한 오답과 실제 정답을 함께 펼친다. */ + /** 시작하면 첫 문제 형식에 맞는 단계로 이동한다. */ @Test - fun onIntent_incorrectAnswer_expandsIncorrectAndCorrectAnswers() { - val viewModel = createViewModel() + fun start_첫문제가객관식이면_객관식단계로이동한다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() - viewModel.onIntent(SolveQuizIntent.Start) - viewModel.onIntent(SolveQuizIntent.AnswerClick("render")) - viewModel.onIntent(SolveQuizIntent.Submit) + viewModel.onIntent(SolveQuizIntent.Start) - assertEquals(setOf("render", "set-content"), viewModel.uiState.value.expandedAnswerIds) + val state = viewModel.uiState.value + assertEquals(QuizStep.MultipleChoice, state.step) + assertEquals("객관식 첫 문제", state.multipleChoiceQuestion.text) + assertEquals(listOf("0", "1"), state.multipleChoiceQuestion.answers.map(QuizAnswer::id)) + assertEquals(listOf("A", "B"), state.multipleChoiceQuestion.answers.map(QuizAnswer::label)) + } } - /** 채점 후 답안과 북마크를 다시 누르면 각각 표시 상태를 전환한다. */ + /** 다음으로 이동하면 문제별 선택·제출 상태가 초기화되고 형식에 맞는 단계로 바뀐다. */ @Test - fun onIntent_resultAnswerAndBookmark_togglesStates() { - val viewModel = createViewModel() - viewModel.onIntent(SolveQuizIntent.Start) - viewModel.onIntent(SolveQuizIntent.AnswerClick("set-content")) - viewModel.onIntent(SolveQuizIntent.Submit) + fun next_다음문제로이동하면_문제별상태를초기화한다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + viewModel.onIntent(SolveQuizIntent.Start) + viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) + viewModel.onIntent(SolveQuizIntent.Submit) - viewModel.onIntent(SolveQuizIntent.AnswerClick("set-content")) - viewModel.onIntent(SolveQuizIntent.BookmarkClick) + viewModel.onIntent(SolveQuizIntent.Next) - assertFalse("set-content" in viewModel.uiState.value.expandedAnswerIds) - assertEquals(setOf(1), viewModel.uiState.value.bookmarkedQuestionNumbers) + val state = viewModel.uiState.value + assertEquals(1, state.currentIndex) + assertEquals(QuizStep.Essay, state.step) + assertEquals(null, state.selectedAnswerId) + assertFalse(state.isMultipleChoiceSubmitted) + assertEquals(emptySet(), state.expandedAnswerIds) + assertEquals("서술형 문제", state.essayQuestion.text) + } } - /** 오답 결과가 선택한 답안과 실제 정답에 서로 다른 디자인 상태를 적용한다. */ + /** 마지막 문제를 마치면 완료 단계로 이동한다. */ @Test - fun answerCardState_incorrectResult_mapsIncorrectAndCorrectStates() { - val state = - SolveQuizUiState( - selectedAnswerId = "render", - isMultipleChoiceSubmitted = true, - expandedAnswerIds = setOf("render", "set-content"), - ) + fun next_마지막문제를마치면_완료단계로이동한다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + viewModel.onIntent(SolveQuizIntent.Start) + viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) + viewModel.onIntent(SolveQuizIntent.Submit) + viewModel.onIntent(SolveQuizIntent.Next) + viewModel.onIntent(SolveQuizIntent.Submit) + viewModel.onIntent(SolveQuizIntent.Next) + viewModel.onIntent(SolveQuizIntent.AnswerClick("1")) + viewModel.onIntent(SolveQuizIntent.Submit) + + viewModel.onIntent(SolveQuizIntent.Next) - assertEquals(GitItMultipleChoiceAnswerState.Incorrect, state.answerCardState("render")) - assertEquals(GitItMultipleChoiceAnswerState.Correct, state.answerCardState("set-content")) - assertEquals(GitItMultipleChoiceAnswerState.Folded, state.answerCardState("set-state")) + assertEquals(QuizStep.Completed, viewModel.uiState.value.step) + } } - /** 객관식 채점 후 다음을 누르면 서술형 문제로 이동한다. */ + /** 제출하지 않은 문제에서는 다음으로 이동하지 않는다. */ @Test - fun onIntent_multipleChoiceNext_movesToEssay() { - val viewModel = createViewModel() - viewModel.onIntent(SolveQuizIntent.Start) - viewModel.onIntent(SolveQuizIntent.AnswerClick("set-content")) - viewModel.onIntent(SolveQuizIntent.Submit) + fun next_제출전이면_이동하지않는다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + viewModel.onIntent(SolveQuizIntent.Start) - viewModel.onIntent(SolveQuizIntent.Next) + viewModel.onIntent(SolveQuizIntent.Next) - assertEquals(QuizStep.Essay, viewModel.uiState.value.step) + assertEquals(0, viewModel.uiState.value.currentIndex) + assertEquals(QuizStep.MultipleChoice, viewModel.uiState.value.step) + } } /** 서술형 답안은 최대 글자 수까지만 저장한다. */ @Test fun onIntent_essayAnswerOverLimit_truncatesToMaximumLength() { - val viewModel = essayViewModel() + runTest(dispatcher) { + val viewModel = essayViewModel() - viewModel.onIntent(SolveQuizIntent.EssayAnswerChange("가".repeat(ESSAY_ANSWER_MAX_LENGTH + 1))) + viewModel.onIntent(SolveQuizIntent.EssayAnswerChange("가".repeat(ESSAY_ANSWER_MAX_LENGTH + 1))) - assertEquals(ESSAY_ANSWER_MAX_LENGTH, viewModel.uiState.value.essayAnswer.length) + assertEquals(ESSAY_ANSWER_MAX_LENGTH, viewModel.uiState.value.essayAnswer.length) + } } - /** 서술형 답안을 비워도 제출하고 완료 단계로 이동할 수 있다. */ + /** 문제 번호별 북마크 토글이 독립적으로 유지된다. */ @Test - fun onIntent_emptyEssay_submitAndNext_movesToCompleted() { - val viewModel = essayViewModel() + fun onIntent_bookmarkEachQuestion_keepsBothQuestionNumbers() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + viewModel.onIntent(SolveQuizIntent.Start) + viewModel.onIntent(SolveQuizIntent.BookmarkClick) + viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) + viewModel.onIntent(SolveQuizIntent.Submit) + viewModel.onIntent(SolveQuizIntent.Next) - viewModel.onIntent(SolveQuizIntent.Submit) - assertTrue(viewModel.uiState.value.isEssaySubmitted) + viewModel.onIntent(SolveQuizIntent.BookmarkClick) - viewModel.onIntent(SolveQuizIntent.Next) - assertEquals(QuizStep.Completed, viewModel.uiState.value.step) + assertEquals(setOf(1, 2), viewModel.uiState.value.bookmarkedQuestionNumbers) + } } - /** 객관식과 서술형 북마크는 문제 번호별로 독립적으로 유지된다. */ + /** 뒤로가기는 진행 상태를 소개 단계로 되돌린다. */ @Test - fun onIntent_bookmarkEachQuestion_keepsBothQuestionNumbers() { - val viewModel = createViewModel() - viewModel.onIntent(SolveQuizIntent.Start) - viewModel.onIntent(SolveQuizIntent.BookmarkClick) - viewModel.onIntent(SolveQuizIntent.AnswerClick("set-content")) - viewModel.onIntent(SolveQuizIntent.Submit) - viewModel.onIntent(SolveQuizIntent.Next) + fun onIntent_backClick_resetsToIntro() { + runTest(dispatcher) { + val viewModel = essayViewModel() - viewModel.onIntent(SolveQuizIntent.BookmarkClick) + viewModel.onIntent(SolveQuizIntent.BackClick) - assertEquals(setOf(1, 2), viewModel.uiState.value.bookmarkedQuestionNumbers) + val state = viewModel.uiState.value + assertEquals(QuizStep.Intro, state.step) + assertEquals(0, state.currentIndex) + assertEquals("", state.essayAnswer) + assertEquals(3, state.questions.size) + } } - /** 학습 완료 화면을 닫으면 다음 진입을 위해 초기 상태로 돌아간다. */ + /** 오답 결과가 선택한 답안과 실제 정답에 서로 다른 디자인 상태를 적용한다. */ @Test - fun onIntent_completedBackClick_resetsState() { - val viewModel = essayViewModel() - viewModel.onIntent(SolveQuizIntent.Submit) - viewModel.onIntent(SolveQuizIntent.Next) - - viewModel.onIntent(SolveQuizIntent.BackClick) + fun answerCardState_incorrectResult_mapsIncorrectAndCorrectStates() { + val gradedQuestion = + QuizQuestion( + id = "q1", + number = 1, + text = "문제", + answers = listOf(QuizAnswer("0", "A", "정답"), QuizAnswer("1", "B", "오답"), QuizAnswer("2", "C", "다른 답")), + correctAnswerId = "0", + ) + val state = + SolveQuizUiState( + questions = listOf(SolveQuizQuestionItem.MultipleChoice(gradedQuestion)), + step = QuizStep.MultipleChoice, + selectedAnswerId = "1", + isMultipleChoiceSubmitted = true, + expandedAnswerIds = setOf("1", "0"), + ) - assertEquals(SolveQuizUiState(), viewModel.uiState.value) + assertEquals(GitItMultipleChoiceAnswerState.Incorrect, state.answerCardState("1")) + assertEquals(GitItMultipleChoiceAnswerState.Correct, state.answerCardState("0")) + assertEquals(GitItMultipleChoiceAnswerState.Folded, state.answerCardState("2")) } /** 서술형 단계까지 이동한 ViewModel을 만든다. */ - private fun essayViewModel(): SolveQuizViewModel = - createViewModel().apply { + private fun TestScope.essayViewModel(): SolveQuizViewModel { + val viewModel = createViewModel() + runCurrent() + return viewModel.apply { onIntent(SolveQuizIntent.Start) - onIntent(SolveQuizIntent.AnswerClick("set-content")) + onIntent(SolveQuizIntent.AnswerClick("0")) onIntent(SolveQuizIntent.Submit) onIntent(SolveQuizIntent.Next) } + } - /** 테스트에서 사용할 프로젝트 식별자가 포함된 ViewModel을 만든다. */ - private fun createViewModel(): SolveQuizViewModel = SolveQuizViewModel(projectId = "project-1") + private fun createViewModel( + repository: FakeSolveQuizRepository = FakeSolveQuizRepository(Result.success(DETAIL), Result.success(LEARNING_SET)), + setId: String? = "s2", + ): SolveQuizViewModel = + SolveQuizViewModel( + args = SolveQuizArgs(projectId = "project-1", setId = setId), + getProjectDetail = GetProjectDetailUseCase(repository), + getLearningSet = GetLearningSetUseCase(repository), + ) + + private companion object { + private val DETAIL = + ProjectDetail( + projectId = "project-1", + repositoryUrl = "https://github.com/facebook/react", + repositoryName = "react", + repositoryImageUrl = "https://example.com/a.png", + starCount = 100, + techStack = listOf("TypeScript"), + overallProgressPercent = 50, + nextProblemId = "q1", + sets = + listOf( + LearningSetSummary(setId = "s1", label = "Set 0", title = "다 푼 세트", problemCount = 3, completedCount = 3), + LearningSetSummary(setId = "s2", label = "Set 1", title = "라우팅 흐름 따라가기", problemCount = 3, completedCount = 1), + ), + ) + + private val LEARNING_SET = + LearningSet( + setId = "s2", + title = "라우팅 흐름 따라가기", + description = "라우팅이 한곳에 모이는 구조를 확인하는 세트", + orientation = "안내", + level = ProjectQuizLevel.L2, + questions = + listOf( + Question( + questionId = "q1", + format = QuestionFormat.MULTIPLE_CHOICE, + text = "객관식 첫 문제", + choices = listOf("정답", "오답"), + sources = + listOf( + QuestionSource( + file = "src/flask/sansio/blueprints.py", + startLine = 1, + endLine = 40, + symbol = "Blueprint", + summary = "블루프린트 설정", + url = "https://github.com/pallets/flask/blob/abc/src/flask/sansio/blueprints.py", + ), + ), + myAnswer = null, + ), + Question( + questionId = "q2", + format = QuestionFormat.ESSAY, + text = "서술형 문제", + choices = emptyList(), + sources = emptyList(), + myAnswer = null, + ), + Question( + questionId = "q3", + format = QuestionFormat.MULTIPLE_CHOICE, + text = "객관식 둘째 문제", + choices = listOf("하나", "둘"), + sources = emptyList(), + myAnswer = null, + ), + ), + ) + } } diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index 83c012fb..12b52ee6 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -7,6 +7,7 @@ import com.nexters.hytime.gitit.domain.repository.GitHubRepositoryRepository import com.nexters.hytime.gitit.domain.repository.MemberRepository import com.nexters.hytime.gitit.domain.repository.ProjectRepository import com.nexters.hytime.gitit.domain.usecase.DeleteProjectUseCase +import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase @@ -21,6 +22,8 @@ import com.nexters.hytime.gitit.feature.quiz.create.QuizCreateViewModel import com.nexters.hytime.gitit.feature.quiz.create.session.QuizCreateRetryHandler import com.nexters.hytime.gitit.feature.quiz.create.session.QuizCreateStore import com.nexters.hytime.gitit.feature.quiz.load.ProjectLoadViewModel +import com.nexters.hytime.gitit.feature.quiz.solve.SolveQuizArgs +import com.nexters.hytime.gitit.feature.quiz.solve.SolveQuizViewModel import com.nexters.hytime.gitit.logging.loggingModule import com.nexters.hytime.gitit.presentation.splash.SplashViewModel import org.koin.core.module.Module @@ -48,6 +51,7 @@ val appModule: Module = single { GetProjectsUseCase(repository = get()) } single { GetProjectDetailUseCase(repository = get()) } single { DeleteProjectUseCase(repository = get()) } + single { GetLearningSetUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } @@ -57,6 +61,9 @@ val appModule: Module = ProjectDetailViewModel(projectId = params.get(), getProjectDetail = get(), deleteProject = get()) } viewModel { ProjectLoadViewModel(loadGitHubRepository = get()) } + viewModel { params -> + SolveQuizViewModel(args = params.get(), getProjectDetail = get(), getLearningSet = get()) + } single { QuizCreateStore() } single { QuizCreateRetryHandler(registerProject = get(), createStore = get()) } viewModel { params -> From d60721e00829063f8f4577a0047a5c2999c2f526 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 14:54:54 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat:=20=EB=AC=B8=EC=A0=9C=20=ED=92=80?= =?UTF-8?q?=EC=9D=B4=EC=97=90=204=EC=A7=80=EC=84=A0=EB=8B=A4=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EC=A0=9C=EC=B6=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/projects/{projectId}/questions/{questionId}/answers/choice를 SubmitChoiceAnswerUseCase로 노출하고 정답 확인 시 서버에 제출한다 - 로컬 채점을 제거하고 서버가 준 정답 번호·해설을 현재 문제에 반영한다 선택한 답과 정답을 함께 펼쳐 기존 결과 화면 규칙을 그대로 따른다 - 제출이 실패하면 미제출 상태를 유지해 같은 답으로 다시 시도할 수 있다 - ktlintCheck, :feature:quiz:jvmTest, :domain:test, :shared:jvmTest 통과 --- .../usecase/SubmitChoiceAnswerUseCase.kt | 27 +++++++ .../feature/quiz/solve/SolveQuizViewModel.kt | 47 +++++++++-- .../quiz/solve/FakeSolveQuizRepository.kt | 15 +++- .../quiz/solve/SolveQuizViewModelTest.kt | 78 +++++++++++++++++-- .../kotlin/com/nexters/hytime/gitit/Di.kt | 9 ++- 5 files changed, 161 insertions(+), 15 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/SubmitChoiceAnswerUseCase.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/SubmitChoiceAnswerUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/SubmitChoiceAnswerUseCase.kt new file mode 100644 index 00000000..72dc9f05 --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/SubmitChoiceAnswerUseCase.kt @@ -0,0 +1,27 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 4지선다 문제의 답을 제출하고 채점 결과를 받는다. + * + * @property repository 프로젝트 정보를 제공하는 도메인 계약 + */ +class SubmitChoiceAnswerUseCase( + private val repository: ProjectRepository, +) { + /** + * 고른 선택지를 서버에 제출한다. 다시 제출하면 이전 답을 덮어쓴다. + * + * @param projectId 문제가 속한 프로젝트 식별자 + * @param questionId 답을 낼 문제 식별자 + * @param selectedIndex 고른 선택지 번호. 0부터 시작한다 + * @return 정답 여부·정답 번호·해설 또는 실패 원인 + */ + suspend operator fun invoke( + projectId: String, + questionId: String, + selectedIndex: Int, + ): Result = repository.submitChoiceAnswer(projectId, questionId, selectedIndex) +} diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt index 5cd6d98b..d92397be 100644 --- a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.viewModelScope import com.nexters.hytime.gitit.domain.model.LearningSetSummary import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase +import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -31,11 +32,13 @@ data class SolveQuizArgs( * @property args 프로젝트·세트 식별자 * @property getProjectDetail 세트 라벨과 이어 풀 세트를 정하기 위한 프로젝트 상세 조회 유스케이스 * @property getLearningSet 문제 목록을 조회하는 유스케이스 + * @property submitChoiceAnswer 4지선다 답을 서버에 제출하고 채점 결과를 받는 유스케이스 */ class SolveQuizViewModel( private val args: SolveQuizArgs, private val getProjectDetail: GetProjectDetailUseCase, private val getLearningSet: GetLearningSetUseCase, + private val submitChoiceAnswer: SubmitChoiceAnswerUseCase, ) : ViewModel() { private val logger = gitItLogger() @@ -146,16 +149,27 @@ class SolveQuizViewModel( } } + /** + * 선택한 답을 서버에 제출하고 채점 결과로 정답·해설을 채운다. + * 실패하면 제출 전 상태를 유지해 다시 시도할 수 있게 하고 원인을 로그로 남긴다. + */ private fun submitMultipleChoiceAnswer(state: SolveQuizUiState) { val selectedAnswerId = state.selectedAnswerId ?: return if (state.isMultipleChoiceSubmitted) return + val selectedIndex = selectedAnswerId.toIntOrNull() ?: return - // TODO: 답변 제출 API 연동 후 서버 채점 결과(정답·해설)를 반영한다. - setState { - copy( - isMultipleChoiceSubmitted = true, - expandedAnswerIds = setOf(selectedAnswerId), - ) + viewModelScope.launch { + submitChoiceAnswer(args.projectId, state.multipleChoiceQuestion.id, selectedIndex) + .onSuccess { result -> + val correctAnswerId = result.answerIndex.toString() + setState { + copy( + questions = questions.withGradedCurrentQuestion(currentIndex, correctAnswerId, result.explanation), + isMultipleChoiceSubmitted = true, + expandedAnswerIds = setOf(selectedAnswerId, correctAnswerId), + ) + } + }.onFailure { error -> logger.e(throwable = error) { "4지선다 답변 제출 실패" } } } } @@ -223,6 +237,27 @@ private fun List.targetSummary(setId: String?): LearningSetS firstOrNull { it.completedCount < it.problemCount } ?: firstOrNull() } +/** + * 현재 객관식 문제에 서버 채점 결과를 채운 목록을 만든다. + * + * @param index 채점한 문제의 위치 + * @param correctAnswerId 정답 답안 식별자 + * @param explanation 채점 후 표시할 해설 + * @return 채점 결과가 반영된 문제 목록 + */ +private fun List.withGradedCurrentQuestion( + index: Int, + correctAnswerId: String, + explanation: String, +): List = + mapIndexed { i, item -> + if (i == index && item is SolveQuizQuestionItem.MultipleChoice) { + item.copy(item.question.copy(correctAnswerId = correctAnswerId, explanation = explanation)) + } else { + item + } + } + /** 문제 형식에 맞는 풀이 단계를 반환한다. */ private fun SolveQuizQuestionItem.toStep(): QuizStep = when (this) { diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt index 2578944d..64e49bfb 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt @@ -15,11 +15,20 @@ import com.nexters.hytime.gitit.domain.repository.ProjectRepository * * @property detailResult 상세 조회에 돌려줄 결과 * @property learningSetResult 학습 세트 조회에 돌려줄 결과 + * @property choiceAnswerResult 4지선다 제출에 돌려줄 결과 */ internal class FakeSolveQuizRepository( private val detailResult: Result, private val learningSetResult: Result, + private val choiceAnswerResult: Result = + Result.failure(IllegalStateException("설정되지 않은 호출입니다.")), ) : ProjectRepository { + /** 마지막으로 4지선다 답을 제출한 문제 식별자다. */ + var submittedChoiceQuestionId: String? = null + + /** 마지막으로 제출한 선택지 번호다. */ + var submittedChoiceIndex: Int? = null + /** 마지막으로 학습 세트를 조회한 세트 식별자다. */ var requestedSetId: String? = null @@ -49,7 +58,11 @@ internal class FakeSolveQuizRepository( projectId: String, questionId: String, selectedIndex: Int, - ): Result = error("호출되면 안 됩니다.") + ): Result { + submittedChoiceQuestionId = questionId + submittedChoiceIndex = selectedIndex + return choiceAnswerResult + } override suspend fun submitEssayAnswer( projectId: String, diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt index c1884793..8da7da73 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt @@ -3,6 +3,7 @@ package com.nexters.hytime.gitit.feature.quiz.solve import com.nexters.hytime.gitit.designsystem.quiz.GitItMultipleChoiceAnswerState +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult import com.nexters.hytime.gitit.domain.model.LearningSet import com.nexters.hytime.gitit.domain.model.LearningSetSummary import com.nexters.hytime.gitit.domain.model.ProjectDetail @@ -12,6 +13,7 @@ import com.nexters.hytime.gitit.domain.model.QuestionFormat import com.nexters.hytime.gitit.domain.model.QuestionSource import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase +import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -101,6 +103,7 @@ class SolveQuizViewModelTest { viewModel.onIntent(SolveQuizIntent.Start) viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() viewModel.onIntent(SolveQuizIntent.Next) @@ -123,11 +126,13 @@ class SolveQuizViewModelTest { viewModel.onIntent(SolveQuizIntent.Start) viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() viewModel.onIntent(SolveQuizIntent.Next) viewModel.onIntent(SolveQuizIntent.Submit) viewModel.onIntent(SolveQuizIntent.Next) viewModel.onIntent(SolveQuizIntent.AnswerClick("1")) viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() viewModel.onIntent(SolveQuizIntent.Next) @@ -172,6 +177,7 @@ class SolveQuizViewModelTest { viewModel.onIntent(SolveQuizIntent.BookmarkClick) viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() viewModel.onIntent(SolveQuizIntent.Next) viewModel.onIntent(SolveQuizIntent.BookmarkClick) @@ -196,6 +202,58 @@ class SolveQuizViewModelTest { } } + /** 제출이 성공하면 서버 채점 결과로 정답과 해설을 채우고 두 답안을 펼친다. */ + @Test + fun submit_채점에성공하면_정답과해설을채운다() { + runTest(dispatcher) { + val repository = + FakeSolveQuizRepository( + Result.success(DETAIL), + Result.success(LEARNING_SET), + Result.success(ChoiceAnswerResult(questionId = "q1", correct = false, answerIndex = 0, explanation = "해설")), + ) + val viewModel = createViewModel(repository = repository) + runCurrent() + viewModel.onIntent(SolveQuizIntent.Start) + viewModel.onIntent(SolveQuizIntent.AnswerClick("1")) + + viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() + + val state = viewModel.uiState.value + assertEquals("q1", repository.submittedChoiceQuestionId) + assertEquals(1, repository.submittedChoiceIndex) + assertTrue(state.isMultipleChoiceSubmitted) + assertEquals("0", state.multipleChoiceQuestion.correctAnswerId) + assertEquals("해설", state.multipleChoiceQuestion.explanation) + assertEquals(setOf("1", "0"), state.expandedAnswerIds) + } + } + + /** 제출이 실패하면 미제출 상태를 유지해 다시 시도할 수 있다. */ + @Test + fun submit_제출이실패하면_미제출상태를유지한다() { + runTest(dispatcher) { + val repository = + FakeSolveQuizRepository( + Result.success(DETAIL), + Result.success(LEARNING_SET), + Result.failure(IllegalStateException("네트워크 오류")), + ) + val viewModel = createViewModel(repository = repository) + runCurrent() + viewModel.onIntent(SolveQuizIntent.Start) + viewModel.onIntent(SolveQuizIntent.AnswerClick("1")) + + viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() + + val state = viewModel.uiState.value + assertFalse(state.isMultipleChoiceSubmitted) + assertEquals("1", state.selectedAnswerId) + } + } + /** 오답 결과가 선택한 답안과 실제 정답에 서로 다른 디자인 상태를 적용한다. */ @Test fun answerCardState_incorrectResult_mapsIncorrectAndCorrectStates() { @@ -225,22 +283,28 @@ class SolveQuizViewModelTest { private fun TestScope.essayViewModel(): SolveQuizViewModel { val viewModel = createViewModel() runCurrent() - return viewModel.apply { - onIntent(SolveQuizIntent.Start) - onIntent(SolveQuizIntent.AnswerClick("0")) - onIntent(SolveQuizIntent.Submit) - onIntent(SolveQuizIntent.Next) - } + viewModel.onIntent(SolveQuizIntent.Start) + viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) + viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() + viewModel.onIntent(SolveQuizIntent.Next) + return viewModel } private fun createViewModel( - repository: FakeSolveQuizRepository = FakeSolveQuizRepository(Result.success(DETAIL), Result.success(LEARNING_SET)), + repository: FakeSolveQuizRepository = + FakeSolveQuizRepository( + Result.success(DETAIL), + Result.success(LEARNING_SET), + Result.success(ChoiceAnswerResult(questionId = "q1", correct = true, answerIndex = 0, explanation = "해설")), + ), setId: String? = "s2", ): SolveQuizViewModel = SolveQuizViewModel( args = SolveQuizArgs(projectId = "project-1", setId = setId), getProjectDetail = GetProjectDetailUseCase(repository), getLearningSet = GetLearningSetUseCase(repository), + submitChoiceAnswer = SubmitChoiceAnswerUseCase(repository), ) private companion object { diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index 12b52ee6..52e745f8 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -14,6 +14,7 @@ import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import com.nexters.hytime.gitit.domain.usecase.LoadGitHubRepositoryUseCase import com.nexters.hytime.gitit.domain.usecase.RegisterProjectUseCase import com.nexters.hytime.gitit.domain.usecase.SignInUseCase +import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase import com.nexters.hytime.gitit.feature.home.HomeViewModel import com.nexters.hytime.gitit.feature.my.MyViewModel import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailViewModel @@ -52,6 +53,7 @@ val appModule: Module = single { GetProjectDetailUseCase(repository = get()) } single { DeleteProjectUseCase(repository = get()) } single { GetLearningSetUseCase(repository = get()) } + single { SubmitChoiceAnswerUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } @@ -62,7 +64,12 @@ val appModule: Module = } viewModel { ProjectLoadViewModel(loadGitHubRepository = get()) } viewModel { params -> - SolveQuizViewModel(args = params.get(), getProjectDetail = get(), getLearningSet = get()) + SolveQuizViewModel( + args = params.get(), + getProjectDetail = get(), + getLearningSet = get(), + submitChoiceAnswer = get(), + ) } single { QuizCreateStore() } single { QuizCreateRetryHandler(registerProject = get(), createStore = get()) } From 45ad9f87393887d16d5088be8b8d83fadbec82e6 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 14:57:01 +0900 Subject: [PATCH 07/14] =?UTF-8?q?feat:=20=EB=AC=B8=EC=A0=9C=20=ED=92=80?= =?UTF-8?q?=EC=9D=B4=EC=97=90=20=EC=84=9C=EC=88=A0=ED=98=95=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EC=A0=9C=EC=B6=9C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/projects/{projectId}/questions/{questionId}/answers/essay를 SubmitEssayAnswerUseCase로 노출하고 정답 확인 시 서버에 제출한다 - 채점 기준의 만점 답안 예시를 결과 화면의 모범 답안으로 표시한다 - 빈 답안은 서버가 400으로 거부하므로 제출하지 않는다 - 제출이 실패하면 작성 중 상태를 유지해 다시 시도할 수 있다 - ktlintCheck, :feature:quiz:jvmTest, :domain:test, :shared:jvmTest 통과 --- .../usecase/SubmitEssayAnswerUseCase.kt | 27 ++++++++ .../feature/quiz/solve/SolveQuizViewModel.kt | 44 +++++++++++- .../quiz/solve/FakeSolveQuizRepository.kt | 15 +++- .../quiz/solve/SolveQuizViewModelTest.kt | 68 ++++++++++++++++++- .../kotlin/com/nexters/hytime/gitit/Di.kt | 3 + 5 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/SubmitEssayAnswerUseCase.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/SubmitEssayAnswerUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/SubmitEssayAnswerUseCase.kt new file mode 100644 index 00000000..bb2e9d90 --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/SubmitEssayAnswerUseCase.kt @@ -0,0 +1,27 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 서술형 문제의 답을 제출하고 해설과 자가채점 기준을 받는다. + * + * @property repository 프로젝트 정보를 제공하는 도메인 계약 + */ +class SubmitEssayAnswerUseCase( + private val repository: ProjectRepository, +) { + /** + * 작성한 답안을 서버에 제출한다. 다시 제출하면 이전 답을 덮어쓴다. + * + * @param projectId 문제가 속한 프로젝트 식별자 + * @param questionId 답을 낼 문제 식별자 + * @param text 서술형 답안. 비어 있으면 서버가 거부한다 + * @return 해설과 자가채점 기준 또는 실패 원인 + */ + suspend operator fun invoke( + projectId: String, + questionId: String, + text: String, + ): Result = repository.submitEssayAnswer(projectId, questionId, text) +} diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt index d92397be..fffd969d 100644 --- a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt @@ -6,6 +6,7 @@ import com.nexters.hytime.gitit.domain.model.LearningSetSummary import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase +import com.nexters.hytime.gitit.domain.usecase.SubmitEssayAnswerUseCase import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -33,12 +34,14 @@ data class SolveQuizArgs( * @property getProjectDetail 세트 라벨과 이어 풀 세트를 정하기 위한 프로젝트 상세 조회 유스케이스 * @property getLearningSet 문제 목록을 조회하는 유스케이스 * @property submitChoiceAnswer 4지선다 답을 서버에 제출하고 채점 결과를 받는 유스케이스 + * @property submitEssayAnswer 서술형 답안을 서버에 제출하고 채점 기준을 받는 유스케이스 */ class SolveQuizViewModel( private val args: SolveQuizArgs, private val getProjectDetail: GetProjectDetailUseCase, private val getLearningSet: GetLearningSetUseCase, private val submitChoiceAnswer: SubmitChoiceAnswerUseCase, + private val submitEssayAnswer: SubmitEssayAnswerUseCase, ) : ViewModel() { private val logger = gitItLogger() @@ -142,7 +145,7 @@ class SolveQuizViewModel( val state = uiState.value when (state.step) { QuizStep.MultipleChoice -> submitMultipleChoiceAnswer(state) - QuizStep.Essay -> setState { copy(isEssaySubmitted = true) } + QuizStep.Essay -> submitEssayAnswerToServer(state) QuizStep.Intro, QuizStep.Completed, -> Unit @@ -173,6 +176,26 @@ class SolveQuizViewModel( } } + /** + * 작성한 답안을 서버에 제출하고 채점 기준의 만점 예시를 모범 답안으로 채운다. + * 실패하면 제출 전 상태를 유지해 다시 시도할 수 있게 하고 원인을 로그로 남긴다. + */ + private fun submitEssayAnswerToServer(state: SolveQuizUiState) { + if (state.isEssaySubmitted || state.essayAnswer.isBlank()) return + + viewModelScope.launch { + submitEssayAnswer(args.projectId, state.essayQuestion.id, state.essayAnswer) + .onSuccess { result -> + setState { + copy( + questions = questions.withEssayModelAnswer(currentIndex, result.rubric.fullMarkExample), + isEssaySubmitted = true, + ) + } + }.onFailure { error -> logger.e(throwable = error) { "서술형 답변 제출 실패" } } + } + } + /** 현재 문제를 마쳤으면 다음 문제로 이동하고, 마지막 문제였다면 완료 화면을 표시한다. */ private fun moveToNextQuestion() { val state = uiState.value @@ -258,6 +281,25 @@ private fun List.withGradedCurrentQuestion( } } +/** + * 현재 서술형 문제에 모범 답안을 채운 목록을 만든다. + * + * @param index 제출한 문제의 위치 + * @param modelAnswer 채점 기준의 만점 답안 예시 + * @return 모범 답안이 반영된 문제 목록 + */ +private fun List.withEssayModelAnswer( + index: Int, + modelAnswer: String, +): List = + mapIndexed { i, item -> + if (i == index && item is SolveQuizQuestionItem.Essay) { + item.copy(item.question.copy(modelAnswer = modelAnswer)) + } else { + item + } + } + /** 문제 형식에 맞는 풀이 단계를 반환한다. */ private fun SolveQuizQuestionItem.toStep(): QuizStep = when (this) { diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt index 64e49bfb..a5562ec4 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt @@ -16,13 +16,22 @@ import com.nexters.hytime.gitit.domain.repository.ProjectRepository * @property detailResult 상세 조회에 돌려줄 결과 * @property learningSetResult 학습 세트 조회에 돌려줄 결과 * @property choiceAnswerResult 4지선다 제출에 돌려줄 결과 + * @property essayAnswerResult 서술형 제출에 돌려줄 결과 */ internal class FakeSolveQuizRepository( private val detailResult: Result, private val learningSetResult: Result, private val choiceAnswerResult: Result = Result.failure(IllegalStateException("설정되지 않은 호출입니다.")), + private val essayAnswerResult: Result = + Result.failure(IllegalStateException("설정되지 않은 호출입니다.")), ) : ProjectRepository { + /** 마지막으로 서술형 답을 제출한 문제 식별자다. */ + var submittedEssayQuestionId: String? = null + + /** 마지막으로 제출한 서술형 답안이다. */ + var submittedEssayText: String? = null + /** 마지막으로 4지선다 답을 제출한 문제 식별자다. */ var submittedChoiceQuestionId: String? = null @@ -68,7 +77,11 @@ internal class FakeSolveQuizRepository( projectId: String, questionId: String, text: String, - ): Result = error("호출되면 안 됩니다.") + ): Result { + submittedEssayQuestionId = questionId + submittedEssayText = text + return essayAnswerResult + } override suspend fun bookmarkQuestion( projectId: String, diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt index 8da7da73..c3d1dd77 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt @@ -4,6 +4,7 @@ package com.nexters.hytime.gitit.feature.quiz.solve import com.nexters.hytime.gitit.designsystem.quiz.GitItMultipleChoiceAnswerState import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult import com.nexters.hytime.gitit.domain.model.LearningSet import com.nexters.hytime.gitit.domain.model.LearningSetSummary import com.nexters.hytime.gitit.domain.model.ProjectDetail @@ -11,9 +12,11 @@ import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel import com.nexters.hytime.gitit.domain.model.Question import com.nexters.hytime.gitit.domain.model.QuestionFormat import com.nexters.hytime.gitit.domain.model.QuestionSource +import com.nexters.hytime.gitit.domain.model.Rubric import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase +import com.nexters.hytime.gitit.domain.usecase.SubmitEssayAnswerUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -121,14 +124,16 @@ class SolveQuizViewModelTest { @Test fun next_마지막문제를마치면_완료단계로이동한다() { runTest(dispatcher) { - val viewModel = createViewModel() + val viewModel = createViewModel(repository = repositoryWithEssayResult()) runCurrent() viewModel.onIntent(SolveQuizIntent.Start) viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) viewModel.onIntent(SolveQuizIntent.Submit) runCurrent() viewModel.onIntent(SolveQuizIntent.Next) + viewModel.onIntent(SolveQuizIntent.EssayAnswerChange("라우터가 한곳에 모여 있습니다")) viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() viewModel.onIntent(SolveQuizIntent.Next) viewModel.onIntent(SolveQuizIntent.AnswerClick("1")) viewModel.onIntent(SolveQuizIntent.Submit) @@ -254,6 +259,40 @@ class SolveQuizViewModelTest { } } + /** 서술형 제출이 성공하면 만점 예시를 모범 답안으로 채운다. */ + @Test + fun submit_서술형제출에성공하면_모범답안을채운다() { + runTest(dispatcher) { + val repository = repositoryWithEssayResult() + val viewModel = essayViewModel(repository) + + viewModel.onIntent(SolveQuizIntent.EssayAnswerChange("라우터가 한곳에 모여 있습니다")) + viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() + + val state = viewModel.uiState.value + assertEquals("q2", repository.submittedEssayQuestionId) + assertEquals("라우터가 한곳에 모여 있습니다", repository.submittedEssayText) + assertTrue(state.isEssaySubmitted) + assertEquals("만점 예시", state.essayQuestion.modelAnswer) + } + } + + /** 빈 답안은 서버가 거부하므로 제출하지 않는다. */ + @Test + fun submit_빈서술형답안이면_제출하지않는다() { + runTest(dispatcher) { + val repository = repositoryWithEssayResult() + val viewModel = essayViewModel(repository) + + viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() + + assertEquals(null, repository.submittedEssayQuestionId) + assertFalse(viewModel.uiState.value.isEssaySubmitted) + } + } + /** 오답 결과가 선택한 답안과 실제 정답에 서로 다른 디자인 상태를 적용한다. */ @Test fun answerCardState_incorrectResult_mapsIncorrectAndCorrectStates() { @@ -280,8 +319,8 @@ class SolveQuizViewModelTest { } /** 서술형 단계까지 이동한 ViewModel을 만든다. */ - private fun TestScope.essayViewModel(): SolveQuizViewModel { - val viewModel = createViewModel() + private fun TestScope.essayViewModel(repository: FakeSolveQuizRepository = repositoryWithEssayResult()): SolveQuizViewModel { + val viewModel = createViewModel(repository = repository) runCurrent() viewModel.onIntent(SolveQuizIntent.Start) viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) @@ -291,6 +330,28 @@ class SolveQuizViewModelTest { return viewModel } + /** 채점 결과가 설정된 기본 페이크 리포지토리를 만든다. */ + private fun repositoryWithEssayResult(): FakeSolveQuizRepository = + FakeSolveQuizRepository( + Result.success(DETAIL), + Result.success(LEARNING_SET), + Result.success(ChoiceAnswerResult(questionId = "q1", correct = true, answerIndex = 0, explanation = "해설")), + Result.success( + EssayAnswerResult( + questionId = "q2", + explanation = "서술형 해설", + rubric = + Rubric( + criteria = emptyList(), + keyPoints = listOf("라우터"), + fullMarkExample = "만점 예시", + partialExample = "부분 예시", + zeroExample = "0점 예시", + ), + ), + ), + ) + private fun createViewModel( repository: FakeSolveQuizRepository = FakeSolveQuizRepository( @@ -305,6 +366,7 @@ class SolveQuizViewModelTest { getProjectDetail = GetProjectDetailUseCase(repository), getLearningSet = GetLearningSetUseCase(repository), submitChoiceAnswer = SubmitChoiceAnswerUseCase(repository), + submitEssayAnswer = SubmitEssayAnswerUseCase(repository), ) private companion object { diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index 52e745f8..88372072 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -15,6 +15,7 @@ import com.nexters.hytime.gitit.domain.usecase.LoadGitHubRepositoryUseCase import com.nexters.hytime.gitit.domain.usecase.RegisterProjectUseCase import com.nexters.hytime.gitit.domain.usecase.SignInUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase +import com.nexters.hytime.gitit.domain.usecase.SubmitEssayAnswerUseCase import com.nexters.hytime.gitit.feature.home.HomeViewModel import com.nexters.hytime.gitit.feature.my.MyViewModel import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailViewModel @@ -54,6 +55,7 @@ val appModule: Module = single { DeleteProjectUseCase(repository = get()) } single { GetLearningSetUseCase(repository = get()) } single { SubmitChoiceAnswerUseCase(repository = get()) } + single { SubmitEssayAnswerUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } @@ -69,6 +71,7 @@ val appModule: Module = getProjectDetail = get(), getLearningSet = get(), submitChoiceAnswer = get(), + submitEssayAnswer = get(), ) } single { QuizCreateStore() } From 061facf5e6bfdce086d80d167dea3413c25a015b Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 14:59:28 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=20=ED=99=94=EB=A9=B4=EC=97=90=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/projects/bookmarks를 GetBookmarkedQuestionsUseCase로 노출하고 진입 시 조회한다 - 더미 목록을 제거하고 전체 필터 뒤에 북마크가 있는 프로젝트 필터를 서버 값으로 잇는다 - 필터를 선택하면 projectId 조건으로 서버에 다시 조회한다 - 카드 메타는 서버 값으로 "프로젝트 · 세트 · 문제 N" 형식을 유지한다 - ktlintCheck, :feature:bookmark:jvmTest, :domain:test, :shared:jvmTest 통과 --- .../usecase/GetBookmarkedQuestionsUseCase.kt | 21 ++++ feature/bookmark/build.gradle.kts | 8 ++ .../feature/bookmark/BookmarkContract.kt | 2 + .../gitit/feature/bookmark/BookmarkMapper.kt | 31 +++++ .../gitit/feature/bookmark/BookmarkRoute.kt | 4 +- .../feature/bookmark/BookmarkViewModel.kt | 54 +++++---- .../feature/bookmark/BookmarkViewModelTest.kt | 110 ++++++++++++++++-- .../bookmark/FakeBookmarkRepository.kt | 69 +++++++++++ .../kotlin/com/nexters/hytime/gitit/Di.kt | 4 + 9 files changed, 265 insertions(+), 38 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetBookmarkedQuestionsUseCase.kt create mode 100644 feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt create mode 100644 feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetBookmarkedQuestionsUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetBookmarkedQuestionsUseCase.kt new file mode 100644 index 00000000..9b9d0233 --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/GetBookmarkedQuestionsUseCase.kt @@ -0,0 +1,21 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 북마크한 문제 목록을 조회한다. + * + * @property repository 프로젝트 정보를 제공하는 도메인 계약 + */ +class GetBookmarkedQuestionsUseCase( + private val repository: ProjectRepository, +) { + /** + * 저장한 문제 화면에 표시할 북마크 목록을 가져온다. + * + * @param projectId 특정 프로젝트로 좁힐 때 사용할 식별자. `null`이면 전체를 조회한다 + * @return 조회된 북마크 목록 또는 실패 원인 + */ + suspend operator fun invoke(projectId: String? = null): Result = repository.getBookmarkedQuestions(projectId) +} diff --git a/feature/bookmark/build.gradle.kts b/feature/bookmark/build.gradle.kts index dff9a22f..1e9b7297 100644 --- a/feature/bookmark/build.gradle.kts +++ b/feature/bookmark/build.gradle.kts @@ -6,11 +6,19 @@ kotlin { sourceSets { commonMain.dependencies { implementation(projects.core.designsystem) + implementation(projects.core.logging) + implementation(projects.domain) + + implementation(libs.koin.compose.viewmodel) implementation(libs.compose.material3) implementation(libs.compose.components.resources) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } } } diff --git a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkContract.kt b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkContract.kt index 9548a17f..0ec88ef6 100644 --- a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkContract.kt +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkContract.kt @@ -30,11 +30,13 @@ data class BookmarkFilter( * 저장한 문제 카드 한 개를 표현한다. * * @property id 문제 식별자 + * @property projectId 문제가 속한 프로젝트 식별자 * @property meta 프로젝트·세트·문제 번호 정보 * @property title 문제 제목 */ data class BookmarkedQuestion( val id: String, + val projectId: String = "", val meta: String, val title: String, ) diff --git a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt new file mode 100644 index 00000000..c267a4a2 --- /dev/null +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt @@ -0,0 +1,31 @@ +package com.nexters.hytime.gitit.feature.bookmark + +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions + +/** 전체 프로젝트를 뜻하는 필터 식별자다. */ +internal const val BOOKMARK_FILTER_ALL_ID = "all" + +/** + * 북마크 도메인 모델을 저장한 문제 화면 상태로 변환한다. + * + * 필터는 전체 항목 뒤에 북마크가 있는 프로젝트들을 잇고, 선택 중인 필터는 유지한다. + * + * @param selectedFilterId 유지할 선택 필터 식별자 + * @return 필터·문제 목록이 채워진 화면 상태 + */ +internal fun BookmarkedQuestions.toUiState(selectedFilterId: String): BookmarkUiState = + BookmarkUiState( + filters = + listOf(BookmarkFilter(id = BOOKMARK_FILTER_ALL_ID, label = "전체")) + + availableProjects.map { project -> BookmarkFilter(id = project.projectId, label = project.projectName) }, + selectedFilterId = selectedFilterId, + questions = + bookmarks.map { bookmark -> + BookmarkedQuestion( + id = bookmark.questionId, + projectId = bookmark.projectId, + meta = "${bookmark.projectName} · ${bookmark.setLabel} · 문제 ${bookmark.problemNumber}", + title = bookmark.question, + ) + }, + ) diff --git a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkRoute.kt b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkRoute.kt index 053fba7a..4b1e727e 100644 --- a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkRoute.kt +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkRoute.kt @@ -4,8 +4,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.flow.collectLatest +import org.koin.compose.viewmodel.koinViewModel /** * 저장한 문제 화면의 진입점(Route)이다. @@ -20,7 +20,7 @@ fun BookmarkRoute( onNavigateToProjectList: () -> Unit, onNavigateToMy: () -> Unit, ) { - val viewModel = viewModel { BookmarkViewModel() } + val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() LaunchedEffect(Unit) { diff --git a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModel.kt b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModel.kt index 740ca04e..66ce8619 100644 --- a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModel.kt +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModel.kt @@ -2,6 +2,8 @@ package com.nexters.hytime.gitit.feature.bookmark import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.GetBookmarkedQuestionsUseCase +import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -12,9 +14,15 @@ import kotlinx.coroutines.launch /** * 저장한 문제 화면의 상태와 사용자 의도를 관리한다. + * + * @property getBookmarkedQuestions 북마크한 문제 목록을 조회하는 유스케이스 */ -class BookmarkViewModel : ViewModel() { - private val _uiState = MutableStateFlow(dummyBookmarkUiState) +class BookmarkViewModel( + private val getBookmarkedQuestions: GetBookmarkedQuestionsUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + private val _uiState = MutableStateFlow(BookmarkUiState(selectedFilterId = BOOKMARK_FILTER_ALL_ID)) /** * 저장한 문제 화면이 구독할 현재 UI 상태이다. @@ -28,6 +36,10 @@ class BookmarkViewModel : ViewModel() { */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadBookmarks(BOOKMARK_FILTER_ALL_ID) + } + /** * 저장한 문제 화면에서 발생한 사용자 의도를 처리한다. * @@ -39,9 +51,7 @@ class BookmarkViewModel : ViewModel() { BookmarkIntent.ProjectTabClick -> emit(BookmarkSideEffect.NavigateToProjectList) BookmarkIntent.SavedTabClick -> Unit BookmarkIntent.MyTabClick -> emit(BookmarkSideEffect.NavigateToMy) - is BookmarkIntent.FilterClick -> { - setState { copy(selectedFilterId = intent.filterId) } - } + is BookmarkIntent.FilterClick -> loadBookmarks(intent.filterId) is BookmarkIntent.BookmarkClick -> { setState { copy( @@ -64,24 +74,18 @@ class BookmarkViewModel : ViewModel() { private fun setState(reducer: BookmarkUiState.() -> BookmarkUiState) { _uiState.value = _uiState.value.reducer() } -} -/** domain/data 연동 전까지 화면에 표시할 더미 저장 문제 상태다. */ -private val dummyBookmarkUiState = - BookmarkUiState( - filters = - listOf( - BookmarkFilter(id = "all", label = "전체"), - BookmarkFilter(id = "flask", label = "Flask"), - BookmarkFilter(id = "android", label = "Now in Android"), - ), - selectedFilterId = "all", - questions = - List(4) { index -> - BookmarkedQuestion( - id = "bookmark-$index", - meta = "Android · Set2 · 문제 1", - title = "sansio/blueprints.py에 정의된 BlueprintSetupState 클래스는 어떤 목적을 가진 개체인가?", - ) - }, - ) + /** + * 선택한 필터로 북마크 목록을 조회해 화면 상태를 채운다. + * 실패하면 이전 목록을 유지하고 원인을 로그로 남긴다. + * + * @param filterId 조회할 프로젝트 필터 식별자. 전체면 프로젝트 조건 없이 조회한다 + */ + private fun loadBookmarks(filterId: String) { + viewModelScope.launch { + getBookmarkedQuestions(projectId = filterId.takeIf { it != BOOKMARK_FILTER_ALL_ID }) + .onSuccess { bookmarks -> _uiState.value = bookmarks.toUiState(selectedFilterId = filterId) } + .onFailure { error -> logger.e(throwable = error) { "북마크 목록 조회 실패" } } + } + } +} diff --git a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt index 37bc07ac..66230da8 100644 --- a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt +++ b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt @@ -1,25 +1,113 @@ +@file:Suppress("ktlint:standard:function-naming") + package com.nexters.hytime.gitit.feature.bookmark +import com.nexters.hytime.gitit.domain.model.AvailableProject +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.usecase.GetBookmarkedQuestionsUseCase +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestion as DomainBookmarkedQuestion -/** 저장한 문제 ViewModel의 북마크 상태 전환을 검증한다. */ +/** 저장한 문제 ViewModel의 목록 조회와 북마크 상태 전환을 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) class BookmarkViewModelTest { + private val dispatcher = StandardTestDispatcher() + + /** ViewModel의 Main dispatcher를 테스트 dispatcher로 교체한다. */ + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + /** 테스트 이후 Main dispatcher를 복원한다. */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** 목록 조회가 성공하면 전체 필터 뒤에 프로젝트 필터를 잇고 문제 카드를 채운다. */ + @Test + fun init_목록조회에성공하면_필터와문제를채운다() { + runTest(dispatcher) { + val repository = FakeBookmarkRepository(Result.success(BOOKMARKS)) + val viewModel = BookmarkViewModel(GetBookmarkedQuestionsUseCase(repository)) + runCurrent() + + val state = viewModel.uiState.value + assertEquals(null, repository.requestedProjectId) + assertEquals(listOf("all", "p1"), state.filters.map(BookmarkFilter::id)) + assertEquals(listOf("전체", "flask"), state.filters.map(BookmarkFilter::label)) + assertEquals("all", state.selectedFilterId) + assertEquals(listOf("q1"), state.questions.map(BookmarkedQuestion::id)) + assertEquals("flask · Set 1 · 문제 3", state.questions.first().meta) + assertEquals("블루프린트의 목적은?", state.questions.first().title) + } + } + + /** 프로젝트 필터를 선택하면 해당 프로젝트로 다시 조회한다. */ + @Test + fun filterClick_프로젝트필터를선택하면_프로젝트로다시조회한다() { + runTest(dispatcher) { + val repository = FakeBookmarkRepository(Result.success(BOOKMARKS)) + val viewModel = BookmarkViewModel(GetBookmarkedQuestionsUseCase(repository)) + runCurrent() + + viewModel.onIntent(BookmarkIntent.FilterClick("p1")) + runCurrent() + + assertEquals("p1", repository.requestedProjectId) + assertEquals(2, repository.loadCount) + assertEquals("p1", viewModel.uiState.value.selectedFilterId) + } + } + /** 저장된 문제를 반복 선택하면 해제 상태와 저장 상태를 차례로 반영한다. */ @Test fun onIntent_bookmarkClick_togglesBookmarkState() { - val viewModel = BookmarkViewModel() - val questionId = - viewModel.uiState.value.questions - .first() - .id + runTest(dispatcher) { + val viewModel = BookmarkViewModel(GetBookmarkedQuestionsUseCase(FakeBookmarkRepository(Result.success(BOOKMARKS)))) + runCurrent() + val questionId = + viewModel.uiState.value.questions + .first() + .id - assertEquals(emptyMap(), viewModel.uiState.value.bookmarkChanges) + assertEquals(emptyMap(), viewModel.uiState.value.bookmarkChanges) - viewModel.onIntent(BookmarkIntent.BookmarkClick(questionId)) - assertEquals(mapOf(questionId to false), viewModel.uiState.value.bookmarkChanges) + viewModel.onIntent(BookmarkIntent.BookmarkClick(questionId)) + assertEquals(mapOf(questionId to false), viewModel.uiState.value.bookmarkChanges) + + viewModel.onIntent(BookmarkIntent.BookmarkClick(questionId)) + assertEquals(mapOf(questionId to true), viewModel.uiState.value.bookmarkChanges) + } + } - viewModel.onIntent(BookmarkIntent.BookmarkClick(questionId)) - assertEquals(mapOf(questionId to true), viewModel.uiState.value.bookmarkChanges) + private companion object { + private val BOOKMARKS = + BookmarkedQuestions( + totalCount = 1, + availableProjects = listOf(AvailableProject(projectId = "p1", projectName = "flask")), + bookmarks = + listOf( + DomainBookmarkedQuestion( + projectId = "p1", + projectName = "flask", + setLabel = "Set 1", + problemNumber = 3, + questionId = "q1", + question = "블루프린트의 목적은?", + ), + ), + ) } } diff --git a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt new file mode 100644 index 00000000..900efb6f --- /dev/null +++ b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt @@ -0,0 +1,69 @@ +package com.nexters.hytime.gitit.feature.bookmark + +import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.model.ChoiceAnswerResult +import com.nexters.hytime.gitit.domain.model.EssayAnswerResult +import com.nexters.hytime.gitit.domain.model.LearningSet +import com.nexters.hytime.gitit.domain.model.ProjectDetail +import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.model.ProjectQuizLevel +import com.nexters.hytime.gitit.domain.model.ProjectRegistration +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 북마크 목록 조회에 지정한 결과만 돌려주는 테스트용 프로젝트 리포지토리다. + * + * @property bookmarksResult 북마크 목록 조회에 돌려줄 결과 + */ +internal class FakeBookmarkRepository( + private val bookmarksResult: Result, +) : ProjectRepository { + /** 마지막으로 목록 조회에 사용한 프로젝트 필터다. */ + var requestedProjectId: String? = null + + /** 목록 조회 호출 횟수다. */ + var loadCount: Int = 0 + + override suspend fun registerProject( + githubRepoUrl: String, + quizLevel: ProjectQuizLevel, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjects( + page: Int, + size: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getProjectDetail(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun deleteProject(projectId: String): Result = error("호출되면 안 됩니다.") + + override suspend fun getLearningSet( + projectId: String, + setId: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitChoiceAnswer( + projectId: String, + questionId: String, + selectedIndex: Int, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun submitEssayAnswer( + projectId: String, + questionId: String, + text: String, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun bookmarkQuestion( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): Result = error("호출되면 안 됩니다.") + + override suspend fun getBookmarkedQuestions(projectId: String?): Result { + requestedProjectId = projectId + loadCount += 1 + return bookmarksResult + } +} diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index 88372072..7a22edac 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -7,6 +7,7 @@ import com.nexters.hytime.gitit.domain.repository.GitHubRepositoryRepository import com.nexters.hytime.gitit.domain.repository.MemberRepository import com.nexters.hytime.gitit.domain.repository.ProjectRepository import com.nexters.hytime.gitit.domain.usecase.DeleteProjectUseCase +import com.nexters.hytime.gitit.domain.usecase.GetBookmarkedQuestionsUseCase import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase @@ -16,6 +17,7 @@ import com.nexters.hytime.gitit.domain.usecase.RegisterProjectUseCase import com.nexters.hytime.gitit.domain.usecase.SignInUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitEssayAnswerUseCase +import com.nexters.hytime.gitit.feature.bookmark.BookmarkViewModel import com.nexters.hytime.gitit.feature.home.HomeViewModel import com.nexters.hytime.gitit.feature.my.MyViewModel import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailViewModel @@ -56,10 +58,12 @@ val appModule: Module = single { GetLearningSetUseCase(repository = get()) } single { SubmitChoiceAnswerUseCase(repository = get()) } single { SubmitEssayAnswerUseCase(repository = get()) } + single { GetBookmarkedQuestionsUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } viewModel { HomeViewModel(getProjects = get()) } + viewModel { BookmarkViewModel(getBookmarkedQuestions = get()) } viewModel { ProjectListViewModel(getProjects = get(), deleteProject = get()) } viewModel { params -> ProjectDetailViewModel(projectId = params.get(), getProjectDetail = get(), deleteProject = get()) From 13ed98f438b9252b1b5da4f1c67462521f942edf Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 15:01:21 +0900 Subject: [PATCH 09/14] =?UTF-8?q?feat:=20=EB=AC=B8=EC=A0=9C=20=EB=B6=81?= =?UTF-8?q?=EB=A7=88=ED=81=AC=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/projects/{projectId}/questions/{questionId}/bookmark를 BookmarkQuestionUseCase로 노출한다 - 문제 풀이·저장한 문제 화면의 로컬 토글을 서버 설정으로 바꾸고, 요청 값 대신 서버가 적용한 상태를 화면에 반영한다 - 설정이 실패하면 표시 상태를 유지하고 로그만 남긴다 - ktlintCheck, :feature:bookmark:jvmTest, :feature:quiz:jvmTest, :domain:test, :shared:jvmTest 통과 --- .../domain/usecase/BookmarkQuestionUseCase.kt | 26 ++++++++++++++ .../feature/bookmark/BookmarkViewModel.kt | 32 ++++++++++++----- .../feature/bookmark/BookmarkViewModelTest.kt | 35 ++++++++++++++++-- .../bookmark/FakeBookmarkRepository.kt | 19 +++++++++- .../feature/quiz/solve/SolveQuizViewModel.kt | 36 +++++++++++++------ .../quiz/solve/FakeSolveQuizRepository.kt | 19 +++++++++- .../quiz/solve/SolveQuizViewModelTest.kt | 27 +++++++++++++- .../kotlin/com/nexters/hytime/gitit/Di.kt | 5 ++- 8 files changed, 173 insertions(+), 26 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/BookmarkQuestionUseCase.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/BookmarkQuestionUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/BookmarkQuestionUseCase.kt new file mode 100644 index 00000000..e3e64e66 --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/BookmarkQuestionUseCase.kt @@ -0,0 +1,26 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.repository.ProjectRepository + +/** + * 문제의 북마크 상태를 서버에 설정한다. + * + * @property repository 프로젝트 정보를 제공하는 도메인 계약 + */ +class BookmarkQuestionUseCase( + private val repository: ProjectRepository, +) { + /** + * 북마크 상태를 설정하고 서버에 적용된 값을 받는다. + * + * @param projectId 문제가 속한 프로젝트 식별자 + * @param questionId 북마크할 문제 식별자 + * @param bookmarked 설정할 북마크 상태 + * @return 서버에 적용된 북마크 상태 또는 실패 원인 + */ + suspend operator fun invoke( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): Result = repository.bookmarkQuestion(projectId, questionId, bookmarked) +} diff --git a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModel.kt b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModel.kt index 66ce8619..a672aba5 100644 --- a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModel.kt +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModel.kt @@ -2,6 +2,7 @@ package com.nexters.hytime.gitit.feature.bookmark import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.BookmarkQuestionUseCase import com.nexters.hytime.gitit.domain.usecase.GetBookmarkedQuestionsUseCase import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow @@ -16,9 +17,11 @@ import kotlinx.coroutines.launch * 저장한 문제 화면의 상태와 사용자 의도를 관리한다. * * @property getBookmarkedQuestions 북마크한 문제 목록을 조회하는 유스케이스 + * @property bookmarkQuestion 문제의 북마크 상태를 서버에 설정하는 유스케이스 */ class BookmarkViewModel( private val getBookmarkedQuestions: GetBookmarkedQuestionsUseCase, + private val bookmarkQuestion: BookmarkQuestionUseCase, ) : ViewModel() { private val logger = gitItLogger() @@ -52,15 +55,7 @@ class BookmarkViewModel( BookmarkIntent.SavedTabClick -> Unit BookmarkIntent.MyTabClick -> emit(BookmarkSideEffect.NavigateToMy) is BookmarkIntent.FilterClick -> loadBookmarks(intent.filterId) - is BookmarkIntent.BookmarkClick -> { - setState { - copy( - bookmarkChanges = - bookmarkChanges + - (intent.questionId to !(bookmarkChanges[intent.questionId] ?: true)), - ) - } - } + is BookmarkIntent.BookmarkClick -> toggleBookmark(intent.questionId) is BookmarkIntent.SolveClick -> { // TODO: 문제풀이 화면 route 추가 후 연결한다. } @@ -75,6 +70,25 @@ class BookmarkViewModel( _uiState.value = _uiState.value.reducer() } + /** + * 문제의 북마크 상태를 반전해 서버에 설정하고, 적용된 값을 화면에 반영한다. + * 실패하면 표시 상태를 유지하고 원인을 로그로 남긴다. + * + * @param questionId 북마크를 전환할 문제 식별자 + */ + private fun toggleBookmark(questionId: String) { + val state = _uiState.value + val question = state.questions.firstOrNull { it.id == questionId } ?: return + val desired = !(state.bookmarkChanges[questionId] ?: true) + + viewModelScope.launch { + bookmarkQuestion(question.projectId, questionId, desired) + .onSuccess { applied -> + setState { copy(bookmarkChanges = bookmarkChanges + (questionId to applied)) } + }.onFailure { error -> logger.e(throwable = error) { "문제 북마크 변경 실패" } } + } + } + /** * 선택한 필터로 북마크 목록을 조회해 화면 상태를 채운다. * 실패하면 이전 목록을 유지하고 원인을 로그로 남긴다. diff --git a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt index 66230da8..03754c69 100644 --- a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt +++ b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt @@ -4,6 +4,7 @@ package com.nexters.hytime.gitit.feature.bookmark import com.nexters.hytime.gitit.domain.model.AvailableProject import com.nexters.hytime.gitit.domain.model.BookmarkedQuestions +import com.nexters.hytime.gitit.domain.usecase.BookmarkQuestionUseCase import com.nexters.hytime.gitit.domain.usecase.GetBookmarkedQuestionsUseCase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -40,7 +41,7 @@ class BookmarkViewModelTest { fun init_목록조회에성공하면_필터와문제를채운다() { runTest(dispatcher) { val repository = FakeBookmarkRepository(Result.success(BOOKMARKS)) - val viewModel = BookmarkViewModel(GetBookmarkedQuestionsUseCase(repository)) + val viewModel = createViewModel(repository) runCurrent() val state = viewModel.uiState.value @@ -59,7 +60,7 @@ class BookmarkViewModelTest { fun filterClick_프로젝트필터를선택하면_프로젝트로다시조회한다() { runTest(dispatcher) { val repository = FakeBookmarkRepository(Result.success(BOOKMARKS)) - val viewModel = BookmarkViewModel(GetBookmarkedQuestionsUseCase(repository)) + val viewModel = createViewModel(repository) runCurrent() viewModel.onIntent(BookmarkIntent.FilterClick("p1")) @@ -75,7 +76,8 @@ class BookmarkViewModelTest { @Test fun onIntent_bookmarkClick_togglesBookmarkState() { runTest(dispatcher) { - val viewModel = BookmarkViewModel(GetBookmarkedQuestionsUseCase(FakeBookmarkRepository(Result.success(BOOKMARKS)))) + val repository = FakeBookmarkRepository(Result.success(BOOKMARKS)) + val viewModel = createViewModel(repository) runCurrent() val questionId = viewModel.uiState.value.questions @@ -85,13 +87,40 @@ class BookmarkViewModelTest { assertEquals(emptyMap(), viewModel.uiState.value.bookmarkChanges) viewModel.onIntent(BookmarkIntent.BookmarkClick(questionId)) + runCurrent() assertEquals(mapOf(questionId to false), viewModel.uiState.value.bookmarkChanges) + assertEquals("p1", repository.bookmarkedProjectId) + assertEquals(questionId, repository.bookmarkedQuestionId) + assertEquals(false, repository.requestedBookmarked) viewModel.onIntent(BookmarkIntent.BookmarkClick(questionId)) + runCurrent() assertEquals(mapOf(questionId to true), viewModel.uiState.value.bookmarkChanges) } } + /** 북마크 설정이 실패하면 표시 상태를 유지한다. */ + @Test + fun bookmarkClick_설정이실패하면_표시상태를유지한다() { + runTest(dispatcher) { + val repository = FakeBookmarkRepository(Result.success(BOOKMARKS)) + repository.bookmarkResult = Result.failure(IllegalStateException("네트워크 오류")) + val viewModel = createViewModel(repository) + runCurrent() + + viewModel.onIntent(BookmarkIntent.BookmarkClick("q1")) + runCurrent() + + assertEquals(emptyMap(), viewModel.uiState.value.bookmarkChanges) + } + } + + private fun createViewModel(repository: FakeBookmarkRepository): BookmarkViewModel = + BookmarkViewModel( + getBookmarkedQuestions = GetBookmarkedQuestionsUseCase(repository), + bookmarkQuestion = BookmarkQuestionUseCase(repository), + ) + private companion object { private val BOOKMARKS = BookmarkedQuestions( diff --git a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt index 900efb6f..b8e3acff 100644 --- a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt +++ b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt @@ -18,6 +18,18 @@ import com.nexters.hytime.gitit.domain.repository.ProjectRepository internal class FakeBookmarkRepository( private val bookmarksResult: Result, ) : ProjectRepository { + /** 북마크 설정 요청이 실패해야 하면 지정하는 결과다. null이면 요청 값을 그대로 적용한다. */ + var bookmarkResult: Result? = null + + /** 마지막으로 북마크를 설정한 문제 식별자다. */ + var bookmarkedQuestionId: String? = null + + /** 마지막으로 북마크를 설정한 프로젝트 식별자다. */ + var bookmarkedProjectId: String? = null + + /** 마지막으로 요청한 북마크 상태다. */ + var requestedBookmarked: Boolean? = null + /** 마지막으로 목록 조회에 사용한 프로젝트 필터다. */ var requestedProjectId: String? = null @@ -59,7 +71,12 @@ internal class FakeBookmarkRepository( projectId: String, questionId: String, bookmarked: Boolean, - ): Result = error("호출되면 안 됩니다.") + ): Result { + bookmarkedQuestionId = questionId + bookmarkedProjectId = projectId + requestedBookmarked = bookmarked + return bookmarkResult ?: Result.success(bookmarked) + } override suspend fun getBookmarkedQuestions(projectId: String?): Result { requestedProjectId = projectId diff --git a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt index fffd969d..681d74d2 100644 --- a/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt +++ b/feature/quiz/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModel.kt @@ -3,6 +3,7 @@ package com.nexters.hytime.gitit.feature.quiz.solve import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.nexters.hytime.gitit.domain.model.LearningSetSummary +import com.nexters.hytime.gitit.domain.usecase.BookmarkQuestionUseCase import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase @@ -35,6 +36,7 @@ data class SolveQuizArgs( * @property getLearningSet 문제 목록을 조회하는 유스케이스 * @property submitChoiceAnswer 4지선다 답을 서버에 제출하고 채점 결과를 받는 유스케이스 * @property submitEssayAnswer 서술형 답안을 서버에 제출하고 채점 기준을 받는 유스케이스 + * @property bookmarkQuestion 문제의 북마크 상태를 서버에 설정하는 유스케이스 */ class SolveQuizViewModel( private val args: SolveQuizArgs, @@ -42,6 +44,7 @@ class SolveQuizViewModel( private val getLearningSet: GetLearningSetUseCase, private val submitChoiceAnswer: SubmitChoiceAnswerUseCase, private val submitEssayAnswer: SubmitEssayAnswerUseCase, + private val bookmarkQuestion: BookmarkQuestionUseCase, ) : ViewModel() { private val logger = gitItLogger() @@ -220,17 +223,30 @@ class SolveQuizViewModel( setState { copy(essayAnswer = answer.take(ESSAY_ANSWER_MAX_LENGTH)) } } + /** + * 현재 문제의 북마크 상태를 반전해 서버에 설정하고, 적용된 값을 화면에 반영한다. + * 실패하면 표시 상태를 유지하고 원인을 로그로 남긴다. + */ private fun toggleBookmark() { - val questionNumber = uiState.value.currentQuestionNumber() - setState { - copy( - bookmarkedQuestionNumbers = - if (questionNumber in bookmarkedQuestionNumbers) { - bookmarkedQuestionNumbers - questionNumber - } else { - bookmarkedQuestionNumbers + questionNumber - }, - ) + val state = uiState.value + val questionItem = state.questions.getOrNull(state.currentIndex) ?: return + val questionNumber = questionItem.number + val desired = questionNumber !in state.bookmarkedQuestionNumbers + + viewModelScope.launch { + bookmarkQuestion(args.projectId, questionItem.questionId, desired) + .onSuccess { applied -> + setState { + copy( + bookmarkedQuestionNumbers = + if (applied) { + bookmarkedQuestionNumbers + questionNumber + } else { + bookmarkedQuestionNumbers - questionNumber + }, + ) + } + }.onFailure { error -> logger.e(throwable = error) { "문제 북마크 변경 실패" } } } } diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt index a5562ec4..604f8b84 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt @@ -26,6 +26,18 @@ internal class FakeSolveQuizRepository( private val essayAnswerResult: Result = Result.failure(IllegalStateException("설정되지 않은 호출입니다.")), ) : ProjectRepository { + /** 북마크 설정 요청이 실패해야 하면 지정하는 결과다. null이면 요청 값을 그대로 적용한다. */ + var bookmarkResult: Result? = null + + /** 마지막으로 북마크를 설정한 문제 식별자다. */ + var bookmarkedQuestionId: String? = null + + /** 마지막으로 북마크를 설정한 프로젝트 식별자다. */ + var bookmarkedProjectId: String? = null + + /** 마지막으로 요청한 북마크 상태다. */ + var requestedBookmarked: Boolean? = null + /** 마지막으로 서술형 답을 제출한 문제 식별자다. */ var submittedEssayQuestionId: String? = null @@ -87,7 +99,12 @@ internal class FakeSolveQuizRepository( projectId: String, questionId: String, bookmarked: Boolean, - ): Result = error("호출되면 안 됩니다.") + ): Result { + bookmarkedQuestionId = questionId + bookmarkedProjectId = projectId + requestedBookmarked = bookmarked + return bookmarkResult ?: Result.success(bookmarked) + } override suspend fun getBookmarkedQuestions(projectId: String?): Result = error("호출되면 안 됩니다.") } diff --git a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt index c3d1dd77..67ce64f7 100644 --- a/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/SolveQuizViewModelTest.kt @@ -13,6 +13,7 @@ import com.nexters.hytime.gitit.domain.model.Question import com.nexters.hytime.gitit.domain.model.QuestionFormat import com.nexters.hytime.gitit.domain.model.QuestionSource import com.nexters.hytime.gitit.domain.model.Rubric +import com.nexters.hytime.gitit.domain.usecase.BookmarkQuestionUseCase import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectDetailUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase @@ -176,21 +177,44 @@ class SolveQuizViewModelTest { @Test fun onIntent_bookmarkEachQuestion_keepsBothQuestionNumbers() { runTest(dispatcher) { - val viewModel = createViewModel() + val repository = repositoryWithEssayResult() + val viewModel = createViewModel(repository = repository) runCurrent() viewModel.onIntent(SolveQuizIntent.Start) viewModel.onIntent(SolveQuizIntent.BookmarkClick) + runCurrent() + assertEquals("q1", repository.bookmarkedQuestionId) + assertEquals(true, repository.requestedBookmarked) viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) viewModel.onIntent(SolveQuizIntent.Submit) runCurrent() viewModel.onIntent(SolveQuizIntent.Next) viewModel.onIntent(SolveQuizIntent.BookmarkClick) + runCurrent() + assertEquals("q2", repository.bookmarkedQuestionId) assertEquals(setOf(1, 2), viewModel.uiState.value.bookmarkedQuestionNumbers) } } + /** 북마크 설정이 실패하면 표시 상태를 유지한다. */ + @Test + fun bookmarkClick_설정이실패하면_표시상태를유지한다() { + runTest(dispatcher) { + val repository = repositoryWithEssayResult() + repository.bookmarkResult = Result.failure(IllegalStateException("네트워크 오류")) + val viewModel = createViewModel(repository = repository) + runCurrent() + viewModel.onIntent(SolveQuizIntent.Start) + + viewModel.onIntent(SolveQuizIntent.BookmarkClick) + runCurrent() + + assertEquals(emptySet(), viewModel.uiState.value.bookmarkedQuestionNumbers) + } + } + /** 뒤로가기는 진행 상태를 소개 단계로 되돌린다. */ @Test fun onIntent_backClick_resetsToIntro() { @@ -367,6 +391,7 @@ class SolveQuizViewModelTest { getLearningSet = GetLearningSetUseCase(repository), submitChoiceAnswer = SubmitChoiceAnswerUseCase(repository), submitEssayAnswer = SubmitEssayAnswerUseCase(repository), + bookmarkQuestion = BookmarkQuestionUseCase(repository), ) private companion object { diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index 7a22edac..68867255 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -6,6 +6,7 @@ import com.nexters.hytime.gitit.domain.repository.AuthRepository import com.nexters.hytime.gitit.domain.repository.GitHubRepositoryRepository import com.nexters.hytime.gitit.domain.repository.MemberRepository import com.nexters.hytime.gitit.domain.repository.ProjectRepository +import com.nexters.hytime.gitit.domain.usecase.BookmarkQuestionUseCase import com.nexters.hytime.gitit.domain.usecase.DeleteProjectUseCase import com.nexters.hytime.gitit.domain.usecase.GetBookmarkedQuestionsUseCase import com.nexters.hytime.gitit.domain.usecase.GetLearningSetUseCase @@ -59,11 +60,12 @@ val appModule: Module = single { SubmitChoiceAnswerUseCase(repository = get()) } single { SubmitEssayAnswerUseCase(repository = get()) } single { GetBookmarkedQuestionsUseCase(repository = get()) } + single { BookmarkQuestionUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } viewModel { HomeViewModel(getProjects = get()) } - viewModel { BookmarkViewModel(getBookmarkedQuestions = get()) } + viewModel { BookmarkViewModel(getBookmarkedQuestions = get(), bookmarkQuestion = get()) } viewModel { ProjectListViewModel(getProjects = get(), deleteProject = get()) } viewModel { params -> ProjectDetailViewModel(projectId = params.get(), getProjectDetail = get(), deleteProject = get()) @@ -76,6 +78,7 @@ val appModule: Module = getLearningSet = get(), submitChoiceAnswer = get(), submitEssayAnswer = get(), + bookmarkQuestion = get(), ) } single { QuizCreateStore() } From 513b065c9e03c66a66990d4cd81a79a742c5fa1a Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 15:16:49 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=EC=97=90=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=EB=B6=84=EC=95=BC=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/members/me/position을 UpdatePositionUseCase로 노출한다 - Figma 시안대로 개발 분야 선택 화면을 추가한다 (node 1535-18281) 디자인시스템 GitItSelectCard·GitItTopBar를 재사용하고 새 컴포넌트는 만들지 않았다 - 디자인 주석대로 카드 선택은 화면 안에서만 반영하고, 뒤로가기에서 저장 후 이전 화면으로 이동한다 선택이 그대로면 저장 요청을 보내지 않고, 저장이 실패해도 이동하며 로그만 남긴다 - 설정 화면의 분야·수준 하드코딩 값을 프로필 조회 값으로 바꾸고, 선택 화면에서 돌아올 때 다시 조회해 최신 값을 표시한다 - 썸네일은 시안의 플레이스홀더 이미지를 온보딩 리소스에서 복사해 사용한다 - ktlintCheck, :feature:my:jvmTest, :domain:test, :shared:jvmTest, :androidApp:compileDebugKotlin 통과 --- .../domain/usecase/UpdatePositionUseCase.kt | 21 +++ .../drawable/settings_select_thumbnail.png | Bin 0 -> 3179 bytes .../composeResources/values/strings.xml | 2 - .../gitit/feature/my/SettingsPositionRoute.kt | 53 +++++++ .../feature/my/SettingsPositionViewModel.kt | 100 ++++++++++++ .../hytime/gitit/feature/my/SettingsRoute.kt | 41 +++++ .../hytime/gitit/feature/my/SettingsScreen.kt | 16 +- .../feature/my/SettingsSelectionScreen.kt | 145 ++++++++++++++++++ .../gitit/feature/my/SettingsViewModel.kt | 56 +++++++ .../my/SettingsPositionViewModelTest.kt | 129 ++++++++++++++++ .../kotlin/com/nexters/hytime/gitit/Di.kt | 6 + .../hytime/gitit/navigation/AppNavHost.kt | 13 +- .../hytime/gitit/navigation/AppRoute.kt | 4 + 13 files changed, 578 insertions(+), 8 deletions(-) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/UpdatePositionUseCase.kt create mode 100644 feature/my/src/commonMain/composeResources/drawable/settings_select_thumbnail.png create mode 100644 feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt create mode 100644 feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionViewModel.kt create mode 100644 feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt create mode 100644 feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsSelectionScreen.kt create mode 100644 feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt create mode 100644 feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionViewModelTest.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/UpdatePositionUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/UpdatePositionUseCase.kt new file mode 100644 index 00000000..a104b80d --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/UpdatePositionUseCase.kt @@ -0,0 +1,21 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.Position +import com.nexters.hytime.gitit.domain.repository.MemberRepository + +/** + * 회원의 개발 분야만 변경한다. 개발 수준에는 영향을 주지 않는다. + * + * @property repository 회원 정보를 제공하는 도메인 계약 + */ +class UpdatePositionUseCase( + private val repository: MemberRepository, +) { + /** + * 새로 선택한 개발 분야를 서버에 저장한다. + * + * @param position 새로 선택한 개발 분야 + * @return 변경 결과 또는 실패 원인 + */ + suspend operator fun invoke(position: Position): Result = repository.updatePosition(position) +} diff --git a/feature/my/src/commonMain/composeResources/drawable/settings_select_thumbnail.png b/feature/my/src/commonMain/composeResources/drawable/settings_select_thumbnail.png new file mode 100644 index 0000000000000000000000000000000000000000..126b647a1fdf8d5058dfded56fccadbf32944a22 GIT binary patch literal 3179 zcmbVPYfw|y7T(#MBnK5FFbENlglM3GB@_#2e1vC21z5nCs-U-k#v(7VaM%3@wIUeZ+&a+ zwResN1;`Di&7Ou3GVopDwFV&;ELn);z|Y2==b{jrA@KEb4^CwDcKF3@_|4dB^#PM} zBN-LpEf<=SO_c`bs>n?B=%T!>Zo;(4$jCPfac}4C`th;R`r@&%(YNDYw}Q1XK0XRX4nRABo1N8$J0!);bFnE`XNQWCLh) z5NmhO9y`WFL4+vo)h}_qi4m%Qq*D+sB0x#2zXQ~c(c8<%KlT@v)Q^4W`9Fjw;jgKZ+cN!uFqnX-z~Usl!*ok(ZRKc88Ixx zsa4$?;(B`oO7XtcL&nM=iTFr+8=WbOr8DJU6qU2!yN1jQ)vomN?T%>7>vl5f5FkSi z`=}nF&K6^B;J3A@%yaPdoLi*FVr~z%HfMVPs$xo4Ta&~DDcHtM-)-eb(wVV z0aQXzk3csKP}3IVc~ee+syGlH4XC*BJI)RMU8i0a&wd+Y@iyjBVafZL(s$`q@6uhC zo?e;pskzlv87g}8iWhbSGI*U6;;vl>8LZp9EQ84>G<-i)>v@brdy}Nn+wdC$LL(F) zO@Yq=#s*}wNv*u4Av8}TP_19OA=v1`w84-p~-I``9wij9K(u zeNuUMW=sk(8{_-}c^dN)ATPNK$23HHpLLSBx@1XK=Vv9KScwmu`#yF{u?cjfiFNa9 zvJ!gS@Aw=#m$dx5Jxb3%$HiI3Mks4e(-YFtU4|^(SI;EGz)rmMOvR8m{xLMS-H8tZ ziIByT{Qf|#W>y28)pG~jDGw0!Mj<~*f#p!3^r8|ooCRH)Wz|?sTKa-3-?ff}7xaU&EVH@CrCIPHD`WFqqRd7x z$x>9OzwwtkRDHt5k>G&X9O;oAM%5>jlPy4hU@G*h7w;SqN{m3e`%Y>V(*oMH{Ch;G zW7E=zS!j3AtD_7|O-7I^;o#j+hlv(w{5LRrI=#<2Vm)}LXW2lKo!+v^mSmIaUCFF? z+r1jHa}~TxL4#x`sNdlD5P1K4$*X)BmU~++JJgpHcj< z@Kf`E0adxeWhOR*-D!3s>JSqHI>(qfHD4lpHm*YIL{n#BJdeeS=f${?Y8Z@@EXgM3 z-zBpoe{5|$#IWH*_VM)s$$Ti|L`$zf^|Fu6+-Y;Nz_y^YtzPRKIup{!ytu`M2oMWU z0Ox1W-sjm=v@eubju^RRDv=wntd&&5v7+85?DzMh4mYrw8KGZ^uq{jrTe@Wy&47*$!Vu^i;*xt16DEG+o<+Mv@d)T6uLVXq*+73_XSa_Q z0ey5N9Mtay`i*VQQaq2v3c_>cR1~yMch4y+8uqUp>%?1sYH%teUy^(<*P2#=4{fH6 zL<(geW#8LxL7S@C%(T!-J*f%%UBTG>`P9Xo<2N8G=I2!Y%~YKk`*B|^y)c{v$C{>oIy zmxmX1aA`9LWk!wI;q##K`Av_P{lp8as6b)wN)tS^zM!CQ(6YnK_t4tkhh8zkfj}cr z^=Cz${?9daC~V-;podl;Q2HyDshCngm7cs}h65p^K-r&}fT{)5NKJyL)&fu#GK~+l z6|(H=X*Zx15L$tfk`?&8Vk(z~7LjHj`H_Z;qMoHfSiTtUSRW4c4Y|-(2Qigu-@&6D zA%Y{5$fvDfVO16@C$0+w=sJ4QRj1zzQ|C$7!$B{t2{1FUi}0Y#fZ0jE4ih>M;DPyx zaM_WqGrN1JNiWKXGhJ12^9VeC8VW{+UP9c z>SQrv+>3IB;8)W+hWxPfx)3GQ*8Gz<4CDDD@jDkwL3L@w(설정 학습 설정 개발 분야 - Back-end 개발 수준 - 입문 알림 세트 생성 완료 알림 켜짐 diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt new file mode 100644 index 00000000..89b09e55 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt @@ -0,0 +1,53 @@ +package com.nexters.hytime.gitit.feature.my + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nexters.hytime.gitit.domain.model.Position +import git_it_kmp.feature.my.generated.resources.Res +import git_it_kmp.feature.my.generated.resources.settings_development_field +import kotlinx.coroutines.flow.collectLatest +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +/** 개발 분야 선택 화면에 표시할 순서 고정 선택지다. */ +private val positionOptions = + listOf( + Position.FRONTEND, + Position.BACKEND, + Position.IOS, + Position.ANDROID, + ) + +/** + * 개발 분야 선택 화면의 진입점(Route)이다. + * + * 뒤로가기를 누르면 변경한 선택을 저장한 뒤 이전 화면으로 이동한다. + * + * @param onNavigateBack 이전 화면으로 돌아가는 콜백 + */ +@Composable +fun SettingsPositionRoute(onNavigateBack: () -> Unit) { + val viewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.sideEffects.collectLatest { sideEffect -> + when (sideEffect) { + SettingsPositionSideEffect.NavigateBack -> onNavigateBack() + } + } + } + + SettingsSelectionScreen( + title = stringResource(Res.string.settings_development_field), + options = + positionOptions.map { position -> + SettingsSelectionOption(id = position.name, title = position.toDisplayLabel()) + }, + selectedOptionId = uiState.selected?.name, + onOptionClick = { option -> viewModel.onPositionClick(Position.valueOf(option.id)) }, + onBackClick = viewModel::onBackClick, + ) +} diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionViewModel.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionViewModel.kt new file mode 100644 index 00000000..d2d35fb4 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionViewModel.kt @@ -0,0 +1,100 @@ +package com.nexters.hytime.gitit.feature.my + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.model.Position +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import com.nexters.hytime.gitit.domain.usecase.UpdatePositionUseCase +import com.nexters.hytime.gitit.logging.gitItLogger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * 개발 분야 선택 화면의 단일 UI 상태다. + * + * @property selected 현재 선택한 개발 분야. 프로필 조회 전이거나 큐레이션 전이면 null + */ +data class SettingsPositionUiState( + val selected: Position? = null, +) + +/** 개발 분야 선택 화면이 한 번만 전달해야 하는 이벤트다. */ +sealed interface SettingsPositionSideEffect { + /** 이전 화면으로 이동. */ + data object NavigateBack : SettingsPositionSideEffect +} + +/** + * 개발 분야 선택과 저장을 관리한다. + * + * 디자인 주석대로 카드 선택은 화면 안에서만 반영하고, 저장은 뒤로가기에서 수행한 뒤 이전 화면으로 이동한다. + * + * @property getMemberProfile 현재 선택된 개발 분야를 알기 위한 프로필 조회 유스케이스 + * @property updatePosition 개발 분야를 서버에 저장하는 유스케이스 + */ +class SettingsPositionViewModel( + private val getMemberProfile: GetMemberProfileUseCase, + private val updatePosition: UpdatePositionUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + /** 화면 진입 시점의 개발 분야다. 값이 그대로면 저장 요청을 보내지 않는다. */ + private var initialPosition: Position? = null + + private val _uiState = MutableStateFlow(SettingsPositionUiState()) + + /** 개발 분야 선택 화면이 구독할 현재 UI 상태이다. */ + val uiState: StateFlow = _uiState.asStateFlow() + + private val _sideEffects = MutableSharedFlow(extraBufferCapacity = 1) + + /** 개발 분야 선택 화면에서 한 번만 처리할 이벤트 스트림이다. */ + val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + + init { + loadCurrentPosition() + } + + /** + * 카드를 눌러 개발 분야를 선택한다. 서버 저장은 뒤로가기에서 일어난다. + * + * @param position 새로 선택한 개발 분야 + */ + fun onPositionClick(position: Position) { + _uiState.value = SettingsPositionUiState(selected = position) + } + + /** + * 선택이 바뀌었으면 서버에 저장하고 이전 화면으로 이동한다. + * 저장이 실패해도 화면은 이동하며 원인을 로그로 남긴다. + */ + fun onBackClick() { + viewModelScope.launch { + val selected = _uiState.value.selected + if (selected != null && selected != initialPosition) { + updatePosition(selected) + .onFailure { error -> logger.e(throwable = error) { "개발 분야 변경 실패" } } + } + _sideEffects.emit(SettingsPositionSideEffect.NavigateBack) + } + } + + /** + * 프로필을 조회해 현재 개발 분야를 선택 상태로 표시한다. + * 실패하면 선택 없이 시작하고 원인을 로그로 남긴다. + */ + private fun loadCurrentPosition() { + viewModelScope.launch { + getMemberProfile() + .onSuccess { profile -> + initialPosition = profile.position + _uiState.value = SettingsPositionUiState(selected = profile.position) + }.onFailure { error -> logger.e(throwable = error) { "개발 분야 조회 실패" } } + } + } +} diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt new file mode 100644 index 00000000..926060d7 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt @@ -0,0 +1,41 @@ +package com.nexters.hytime.gitit.feature.my + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.koin.compose.viewmodel.koinViewModel + +/** + * 설정 화면의 진입점(Route)이다. + * + * 선택 화면에서 돌아올 때 최신 값을 표시하도록 화면이 나타날 때마다 프로필을 다시 조회한다. + * + * @param onBackClick 이전 화면으로 돌아가는 콜백 + * @param onPolicyClick 서비스 약관 및 정책 링크를 열도록 요청하는 콜백 + * @param onDeleteAccountClick 계정 삭제 안내 화면으로 이동하는 콜백 + * @param onDevelopmentFieldClick 개발 분야 선택 화면으로 이동하는 콜백 + */ +@Composable +fun SettingsRoute( + onBackClick: () -> Unit, + onPolicyClick: () -> Unit, + onDeleteAccountClick: () -> Unit, + onDevelopmentFieldClick: () -> Unit, +) { + val viewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.refresh() + } + + SettingsScreen( + developmentField = uiState.developmentField, + learningLevel = uiState.learningLevel, + onBackClick = onBackClick, + onPolicyClick = onPolicyClick, + onDeleteAccountClick = onDeleteAccountClick, + onDevelopmentFieldClick = onDevelopmentFieldClick, + ) +} diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsScreen.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsScreen.kt index b1c0ea71..0fcb7fc3 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsScreen.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsScreen.kt @@ -35,9 +35,7 @@ import git_it_kmp.feature.my.generated.resources.settings_delete_account import git_it_kmp.feature.my.generated.resources.settings_delete_icon import git_it_kmp.feature.my.generated.resources.settings_develop import git_it_kmp.feature.my.generated.resources.settings_development_field -import git_it_kmp.feature.my.generated.resources.settings_development_field_value import git_it_kmp.feature.my.generated.resources.settings_development_level -import git_it_kmp.feature.my.generated.resources.settings_development_level_value import git_it_kmp.feature.my.generated.resources.settings_general import git_it_kmp.feature.my.generated.resources.settings_learning import git_it_kmp.feature.my.generated.resources.settings_level @@ -56,16 +54,22 @@ import org.jetbrains.compose.resources.stringResource /** * 학습 환경과 계정 관련 메뉴를 표시하는 설정 화면이다. * + * @param developmentField 현재 선택한 개발 분야 표기 라벨. 큐레이션 전이면 빈 문자열 + * @param learningLevel 현재 선택한 개발 수준 표기 라벨. 큐레이션 전이면 빈 문자열 * @param onBackClick 이전 화면으로 돌아가는 콜백 * @param onPolicyClick 서비스 약관 및 정책 링크를 열도록 요청하는 콜백 * @param onDeleteAccountClick 계정 삭제 안내 화면으로 이동하는 콜백 + * @param onDevelopmentFieldClick 개발 분야 선택 화면으로 이동하는 콜백 * @param modifier 화면의 크기와 배치를 지정할 수식자 */ @Composable fun SettingsScreen( + developmentField: String, + learningLevel: String, onBackClick: () -> Unit, onPolicyClick: () -> Unit, onDeleteAccountClick: () -> Unit, + onDevelopmentFieldClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -94,13 +98,14 @@ fun SettingsScreen( SettingsRow( icon = SettingsIconType.Develop, label = stringResource(Res.string.settings_development_field), - value = stringResource(Res.string.settings_development_field_value), + value = developmentField, + onClick = onDevelopmentFieldClick, ) SettingsDivider() SettingsRow( icon = SettingsIconType.Level, label = stringResource(Res.string.settings_development_level), - value = stringResource(Res.string.settings_development_level_value), + value = learningLevel, ) } @@ -314,9 +319,12 @@ private fun SettingsIcon( private fun SettingsScreenPreview() { GitItTheme { SettingsScreen( + developmentField = "Back-end", + learningLevel = "입문", onBackClick = {}, onPolicyClick = {}, onDeleteAccountClick = {}, + onDevelopmentFieldClick = {}, ) } } diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsSelectionScreen.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsSelectionScreen.kt new file mode 100644 index 00000000..3faaaa30 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsSelectionScreen.kt @@ -0,0 +1,145 @@ +package com.nexters.hytime.gitit.feature.my + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.nexters.hytime.gitit.designsystem.GitItTheme +import com.nexters.hytime.gitit.designsystem.selectcard.GitItSelectCard +import com.nexters.hytime.gitit.designsystem.toolbar.GitItTopBar +import com.nexters.hytime.gitit.designsystem.toolbar.GitItTopBarType +import git_it_kmp.feature.my.generated.resources.Res +import git_it_kmp.feature.my.generated.resources.settings_select_thumbnail +import org.jetbrains.compose.resources.painterResource + +/** + * 설정 선택 화면의 카드 한 장을 표현한다. + * + * @property id 선택 상태와 목록 key에 사용하는 화면 내 고유 식별자 + * @property title 선택지를 한 줄로 설명하는 제목 + * @property description 제목 아래에 표시할 선택적 한 줄 설명 + */ +data class SettingsSelectionOption( + val id: String, + val title: String, + val description: String? = null, +) + +/** + * 설정에서 개발 분야·수준 하나를 고르는 선택 화면이다. + * + * 선택 상태는 호출자가 [selectedOptionId]로 소유하고, 카드를 누르면 [onOptionClick]만 전달한다. + * 저장은 뒤로가기에서 일어나므로 이 화면은 별도 확인 버튼을 두지 않는다. + * + * @param title 상단 큰 제목에 표시할 설정 이름 + * @param options 카드로 표시할 선택지 목록. 각 항목의 id는 화면 안에서 고유해야 한다 + * @param selectedOptionId 현재 선택된 항목 식별자. 값이 없으면 null + * @param onOptionClick 사용자가 선택 카드를 누르면 해당 항목과 함께 호출할 이벤트 + * @param onBackClick 사용자가 상단 뒤로가기 버튼을 누르면 호출할 이벤트 + * @param modifier 화면의 크기와 외부 배치를 지정할 수식자 + */ +@Composable +fun SettingsSelectionScreen( + title: String, + options: List, + selectedOptionId: String?, + onOptionClick: (SettingsSelectionOption) -> Unit, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxSize() + .background(GitItTheme.colors.grey700) + .statusBarsPadding(), + ) { + GitItTopBar( + type = GitItTopBarType.LargeTitle, + title = title, + onBackClick = onBackClick, + modifier = Modifier.padding(top = 8.dp), + ) + LazyColumn( + modifier = + Modifier + .fillMaxWidth() + .weight(1f) + .padding(horizontal = 20.dp) + .selectableGroup(), + contentPadding = PaddingValues(top = 16.dp, bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items( + items = options, + key = SettingsSelectionOption::id, + ) { option -> + GitItSelectCard( + title = option.title, + description = option.description, + selected = option.id == selectedOptionId, + onClick = { onOptionClick(option) }, + modifier = Modifier.fillMaxWidth(), + ) { + Image( + painter = painterResource(Res.drawable.settings_select_thumbnail), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + } + } + } +} + +@Preview +@Composable +private fun SettingsSelectionScreenPreview() { + GitItTheme { + SettingsSelectionScreen( + title = "개발 분야", + options = + listOf( + SettingsSelectionOption(id = "FRONTEND", title = "Front-end"), + SettingsSelectionOption(id = "BACKEND", title = "Back-end"), + SettingsSelectionOption(id = "IOS", title = "iOS"), + SettingsSelectionOption(id = "ANDROID", title = "Android"), + ), + selectedOptionId = "BACKEND", + onOptionClick = {}, + onBackClick = {}, + ) + } +} + +@Preview +@Composable +private fun SettingsSelectionScreenWithDescriptionPreview() { + GitItTheme { + SettingsSelectionScreen( + title = "개발 수준", + options = + listOf( + SettingsSelectionOption(id = "ENTRY", title = "입문", description = "프로젝트 코드를 처음 살펴봐요."), + SettingsSelectionOption(id = "JUNIOR", title = "주니어", description = "작은 기능 단위로 코드를 이해할 수 있어요."), + ), + selectedOptionId = "JUNIOR", + onOptionClick = {}, + onBackClick = {}, + ) + } +} diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt new file mode 100644 index 00000000..ee12a13a --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt @@ -0,0 +1,56 @@ +package com.nexters.hytime.gitit.feature.my + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import com.nexters.hytime.gitit.logging.gitItLogger +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * 설정 화면의 단일 UI 상태다. + * + * @property developmentField 현재 선택한 개발 분야 표기 라벨. 큐레이션 전이면 빈 문자열 + * @property learningLevel 현재 선택한 개발 수준 표기 라벨. 큐레이션 전이면 빈 문자열 + */ +data class SettingsUiState( + val developmentField: String = "", + val learningLevel: String = "", +) + +/** + * 설정 화면에 표시할 회원의 현재 학습 설정을 관리한다. + * + * @property getMemberProfile 회원 프로필을 조회하는 유스케이스 + */ +class SettingsViewModel( + private val getMemberProfile: GetMemberProfileUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + private val _uiState = MutableStateFlow(SettingsUiState()) + + /** 설정 화면이 구독할 현재 UI 상태이다. */ + val uiState: StateFlow = _uiState.asStateFlow() + + /** + * 회원 프로필을 조회해 현재 개발 분야·수준 라벨을 갱신한다. + * + * 선택 화면에서 값을 바꾸고 돌아온 경우를 반영해야 하므로 화면에 진입할 때마다 호출한다. + * 실패하면 이전 값을 유지하고 원인을 로그로 남긴다. + */ + fun refresh() { + viewModelScope.launch { + getMemberProfile() + .onSuccess { profile -> + _uiState.value = + SettingsUiState( + developmentField = profile.position.toDisplayLabel(), + learningLevel = profile.careerLevel.toDisplayLabel(), + ) + }.onFailure { error -> logger.e(throwable = error) { "설정 프로필 조회 실패" } } + } + } +} diff --git a/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionViewModelTest.kt b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionViewModelTest.kt new file mode 100644 index 00000000..4276d7f2 --- /dev/null +++ b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionViewModelTest.kt @@ -0,0 +1,129 @@ +@file:Suppress("ktlint:standard:function-naming") + +package com.nexters.hytime.gitit.feature.my + +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.MemberProfile +import com.nexters.hytime.gitit.domain.model.Position +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import com.nexters.hytime.gitit.domain.usecase.UpdatePositionUseCase +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** 개발 분야 선택 화면의 선택·저장 흐름을 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsPositionViewModelTest { + private val dispatcher = StandardTestDispatcher() + + /** ViewModel의 Main dispatcher를 테스트 dispatcher로 교체한다. */ + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + /** 테스트 이후 Main dispatcher를 복원한다. */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** 진입하면 프로필의 현재 개발 분야를 선택 상태로 표시한다. */ + @Test + fun init_프로필을조회하면_현재분야를선택한다() { + runTest(dispatcher) { + val repository = FakeMemberRepository(profileResult = Result.success(PROFILE)) + val viewModel = createViewModel(repository) + runCurrent() + + assertEquals(Position.BACKEND, viewModel.uiState.value.selected) + } + } + + /** 선택을 바꾸고 뒤로가면 서버에 저장한 뒤 이전 화면으로 이동한다. */ + @Test + fun backClick_선택이바뀌면_저장하고이전화면으로이동한다() { + runTest(dispatcher) { + val repository = FakeMemberRepository(profileResult = Result.success(PROFILE)) + val viewModel = createViewModel(repository) + runCurrent() + val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } + + viewModel.onPositionClick(Position.ANDROID) + viewModel.onBackClick() + runCurrent() + + assertEquals(Position.ANDROID, repository.updatedPosition) + assertEquals(SettingsPositionSideEffect.NavigateBack, sideEffect.await()) + } + } + + /** 선택이 그대로면 저장 요청 없이 이전 화면으로 이동한다. */ + @Test + fun backClick_선택이그대로면_저장하지않고이동한다() { + runTest(dispatcher) { + val repository = FakeMemberRepository(profileResult = Result.success(PROFILE)) + val viewModel = createViewModel(repository) + runCurrent() + val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } + + viewModel.onBackClick() + runCurrent() + + assertEquals(null, repository.updatedPosition) + assertEquals(SettingsPositionSideEffect.NavigateBack, sideEffect.await()) + } + } + + /** 저장이 실패해도 이전 화면으로 이동한다. */ + @Test + fun backClick_저장이실패해도_이전화면으로이동한다() { + runTest(dispatcher) { + val repository = + FakeMemberRepository( + profileResult = Result.success(PROFILE), + updateResult = Result.failure(IllegalStateException("네트워크 오류")), + ) + val viewModel = createViewModel(repository) + runCurrent() + val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } + + viewModel.onPositionClick(Position.IOS) + viewModel.onBackClick() + runCurrent() + + assertEquals(SettingsPositionSideEffect.NavigateBack, sideEffect.await()) + } + } + + private fun createViewModel(repository: FakeMemberRepository): SettingsPositionViewModel = + SettingsPositionViewModel( + getMemberProfile = GetMemberProfileUseCase(repository), + updatePosition = UpdatePositionUseCase(repository), + ) + + private companion object { + private val PROFILE = + MemberProfile( + name = "김이박", + email = "gitit@example.com", + position = Position.BACKEND, + careerLevel = CareerLevel.JUNIOR, + thisWeekSolvedCount = 0, + thisMonthSolvedCount = 0, + streakDays = 0, + weeklyChart = emptyList(), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index 68867255..08f0679d 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -18,9 +18,12 @@ import com.nexters.hytime.gitit.domain.usecase.RegisterProjectUseCase import com.nexters.hytime.gitit.domain.usecase.SignInUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitEssayAnswerUseCase +import com.nexters.hytime.gitit.domain.usecase.UpdatePositionUseCase import com.nexters.hytime.gitit.feature.bookmark.BookmarkViewModel import com.nexters.hytime.gitit.feature.home.HomeViewModel import com.nexters.hytime.gitit.feature.my.MyViewModel +import com.nexters.hytime.gitit.feature.my.SettingsPositionViewModel +import com.nexters.hytime.gitit.feature.my.SettingsViewModel import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailViewModel import com.nexters.hytime.gitit.feature.projectlist.ProjectListViewModel import com.nexters.hytime.gitit.feature.quiz.create.QuizCreateViewModel @@ -61,9 +64,12 @@ val appModule: Module = single { SubmitEssayAnswerUseCase(repository = get()) } single { GetBookmarkedQuestionsUseCase(repository = get()) } single { BookmarkQuestionUseCase(repository = get()) } + single { UpdatePositionUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } + viewModel { SettingsViewModel(getMemberProfile = get()) } + viewModel { SettingsPositionViewModel(getMemberProfile = get(), updatePosition = get()) } viewModel { HomeViewModel(getProjects = get()) } viewModel { BookmarkViewModel(getBookmarkedQuestions = get(), bookmarkQuestion = get()) } viewModel { ProjectListViewModel(getProjects = get(), deleteProject = get()) } diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt index 9b22ad55..7d922c60 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt @@ -31,7 +31,8 @@ import com.nexters.hytime.gitit.feature.bookmark.BookmarkRoute import com.nexters.hytime.gitit.feature.home.HomeRoute import com.nexters.hytime.gitit.feature.my.AccountDeleteRoute import com.nexters.hytime.gitit.feature.my.MyRoute -import com.nexters.hytime.gitit.feature.my.SettingsScreen +import com.nexters.hytime.gitit.feature.my.SettingsPositionRoute +import com.nexters.hytime.gitit.feature.my.SettingsRoute import com.nexters.hytime.gitit.feature.onboarding.OnboardingRoute import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailRoute import com.nexters.hytime.gitit.feature.projectlist.ProjectListRoute @@ -73,6 +74,7 @@ internal val appRouteSavedStateConfiguration = subclass(AppRoute.Home.serializer()) subclass(AppRoute.My.serializer()) subclass(AppRoute.Settings.serializer()) + subclass(AppRoute.SettingsPosition.serializer()) subclass(AppRoute.AccountDelete.serializer()) subclass(AppRoute.LiquidGlassExample.serializer()) subclass(AppRoute.Onboarding.serializer()) @@ -116,6 +118,7 @@ internal fun AppRoute.navigationMotion(): AppNavigationMotion = AppRoute.LiquidGlassExample, is AppRoute.ProjectDetail, AppRoute.Settings, + AppRoute.SettingsPosition, -> AppNavigationMotion.Horizontal AppRoute.ProjectLoad, @@ -273,10 +276,16 @@ fun AppNavHost() { } } entry(metadata = AppRoute.Settings.navigationMetadata()) { - SettingsScreen( + SettingsRoute( onBackClick = { backStack.removeLastOrNull() }, onPolicyClick = { uriHandler.openUri(POLICY_URL) }, onDeleteAccountClick = { backStack.add(AppRoute.AccountDelete) }, + onDevelopmentFieldClick = { backStack.add(AppRoute.SettingsPosition) }, + ) + } + entry(metadata = AppRoute.SettingsPosition.navigationMetadata()) { + SettingsPositionRoute( + onNavigateBack = { backStack.removeLastOrNull() }, ) } entry(metadata = AppRoute.AccountDelete.navigationMetadata()) { diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt index 2bd1b0e9..f9da99ba 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt @@ -46,6 +46,10 @@ sealed interface AppRoute : NavKey { @Serializable data object Settings : AppRoute + /** 설정에서 개발 분야를 변경하는 선택 화면이다. */ + @Serializable + data object SettingsPosition : AppRoute + /** 계정 삭제 전 주의사항을 안내하는 화면이다. */ @Serializable data object AccountDelete : AppRoute From 7c0c360dda9c44328412152bb6dd5abbb0b47912 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 15:18:50 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=EC=97=90=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=EC=88=98=EC=A4=80=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /api/v1/members/me/career-level을 UpdateCareerLevelUseCase로 노출한다 - Figma 시안대로 개발 수준 선택 화면을 추가한다 (node 1535-18378) 개발 분야와 같은 SettingsSelectionScreen을 쓰고 수준별 설명 문구는 시안 그대로 담았다 - 디자인 주석대로 카드 선택은 화면 안에서만 반영하고, 뒤로가기에서 저장 후 이전 화면으로 이동한다 - 설정 화면의 개발 수준 행을 선택 화면으로 연결한다 - ktlintCheck, :feature:my:jvmTest, :domain:test, :shared:jvmTest, :shared:testAndroidHostTest, :androidApp:compileDebugKotlin 통과 --- .../usecase/UpdateCareerLevelUseCase.kt | 21 +++ .../feature/my/SettingsCareerLevelRoute.kt | 62 +++++++++ .../my/SettingsCareerLevelViewModel.kt | 100 ++++++++++++++ .../hytime/gitit/feature/my/SettingsRoute.kt | 3 + .../hytime/gitit/feature/my/SettingsScreen.kt | 4 + .../my/SettingsCareerLevelViewModelTest.kt | 129 ++++++++++++++++++ .../kotlin/com/nexters/hytime/gitit/Di.kt | 4 + .../hytime/gitit/navigation/AppNavHost.kt | 9 ++ .../hytime/gitit/navigation/AppRoute.kt | 4 + 9 files changed, 336 insertions(+) create mode 100644 domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/UpdateCareerLevelUseCase.kt create mode 100644 feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt create mode 100644 feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelViewModel.kt create mode 100644 feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelViewModelTest.kt diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/UpdateCareerLevelUseCase.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/UpdateCareerLevelUseCase.kt new file mode 100644 index 00000000..9194b415 --- /dev/null +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/usecase/UpdateCareerLevelUseCase.kt @@ -0,0 +1,21 @@ +package com.nexters.hytime.gitit.domain.usecase + +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.repository.MemberRepository + +/** + * 회원의 개발 수준만 변경한다. 개발 분야에는 영향을 주지 않는다. + * + * @property repository 회원 정보를 제공하는 도메인 계약 + */ +class UpdateCareerLevelUseCase( + private val repository: MemberRepository, +) { + /** + * 새로 선택한 개발 수준을 서버에 저장한다. + * + * @param careerLevel 새로 선택한 개발 수준 + * @return 변경 결과 또는 실패 원인 + */ + suspend operator fun invoke(careerLevel: CareerLevel): Result = repository.updateCareerLevel(careerLevel) +} diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt new file mode 100644 index 00000000..5a45dea7 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt @@ -0,0 +1,62 @@ +package com.nexters.hytime.gitit.feature.my + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nexters.hytime.gitit.domain.model.CareerLevel +import git_it_kmp.feature.my.generated.resources.Res +import git_it_kmp.feature.my.generated.resources.settings_development_level +import kotlinx.coroutines.flow.collectLatest +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +/** + * 개발 수준 카드에 표시할 수준별 설명이다. Figma 시안의 문구를 그대로 쓴다. + * + * @param careerLevel 설명을 찾을 개발 수준 + * @return 카드 제목 아래에 표시할 한 줄 설명 + */ +private fun careerLevelDescription(careerLevel: CareerLevel): String = + when (careerLevel) { + CareerLevel.ENTRY -> "프로젝트 코드를 처음 살펴봐요." + CareerLevel.JUNIOR -> "작은 기능 단위로 코드를 이해할 수 있어요." + CareerLevel.MIDDLE -> "프로젝트 구조와 흐름을 함께 살펴봐요." + CareerLevel.SENIOR -> "설계 의도와 변경 영향을 분석할 수 있어요." + } + +/** + * 개발 수준 선택 화면의 진입점(Route)이다. + * + * 뒤로가기를 누르면 변경한 선택을 저장한 뒤 이전 화면으로 이동한다. + * + * @param onNavigateBack 이전 화면으로 돌아가는 콜백 + */ +@Composable +fun SettingsCareerLevelRoute(onNavigateBack: () -> Unit) { + val viewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.sideEffects.collectLatest { sideEffect -> + when (sideEffect) { + SettingsCareerLevelSideEffect.NavigateBack -> onNavigateBack() + } + } + } + + SettingsSelectionScreen( + title = stringResource(Res.string.settings_development_level), + options = + CareerLevel.entries.map { careerLevel -> + SettingsSelectionOption( + id = careerLevel.name, + title = careerLevel.toDisplayLabel(), + description = careerLevelDescription(careerLevel), + ) + }, + selectedOptionId = uiState.selected?.name, + onOptionClick = { option -> viewModel.onCareerLevelClick(CareerLevel.valueOf(option.id)) }, + onBackClick = viewModel::onBackClick, + ) +} diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelViewModel.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelViewModel.kt new file mode 100644 index 00000000..808bcbdd --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelViewModel.kt @@ -0,0 +1,100 @@ +package com.nexters.hytime.gitit.feature.my + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import com.nexters.hytime.gitit.domain.usecase.UpdateCareerLevelUseCase +import com.nexters.hytime.gitit.logging.gitItLogger +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * 개발 수준 선택 화면의 단일 UI 상태다. + * + * @property selected 현재 선택한 개발 수준. 프로필 조회 전이거나 큐레이션 전이면 null + */ +data class SettingsCareerLevelUiState( + val selected: CareerLevel? = null, +) + +/** 개발 수준 선택 화면이 한 번만 전달해야 하는 이벤트다. */ +sealed interface SettingsCareerLevelSideEffect { + /** 이전 화면으로 이동. */ + data object NavigateBack : SettingsCareerLevelSideEffect +} + +/** + * 개발 수준 선택과 저장을 관리한다. + * + * 디자인 주석대로 카드 선택은 화면 안에서만 반영하고, 저장은 뒤로가기에서 수행한 뒤 이전 화면으로 이동한다. + * + * @property getMemberProfile 현재 선택된 개발 수준을 알기 위한 프로필 조회 유스케이스 + * @property updateCareerLevel 개발 수준을 서버에 저장하는 유스케이스 + */ +class SettingsCareerLevelViewModel( + private val getMemberProfile: GetMemberProfileUseCase, + private val updateCareerLevel: UpdateCareerLevelUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + /** 화면 진입 시점의 개발 수준이다. 값이 그대로면 저장 요청을 보내지 않는다. */ + private var initialCareerLevel: CareerLevel? = null + + private val _uiState = MutableStateFlow(SettingsCareerLevelUiState()) + + /** 개발 수준 선택 화면이 구독할 현재 UI 상태이다. */ + val uiState: StateFlow = _uiState.asStateFlow() + + private val _sideEffects = MutableSharedFlow(extraBufferCapacity = 1) + + /** 개발 수준 선택 화면에서 한 번만 처리할 이벤트 스트림이다. */ + val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + + init { + loadCurrentCareerLevel() + } + + /** + * 카드를 눌러 개발 수준을 선택한다. 서버 저장은 뒤로가기에서 일어난다. + * + * @param careerLevel 새로 선택한 개발 수준 + */ + fun onCareerLevelClick(careerLevel: CareerLevel) { + _uiState.value = SettingsCareerLevelUiState(selected = careerLevel) + } + + /** + * 선택이 바뀌었으면 서버에 저장하고 이전 화면으로 이동한다. + * 저장이 실패해도 화면은 이동하며 원인을 로그로 남긴다. + */ + fun onBackClick() { + viewModelScope.launch { + val selected = _uiState.value.selected + if (selected != null && selected != initialCareerLevel) { + updateCareerLevel(selected) + .onFailure { error -> logger.e(throwable = error) { "개발 수준 변경 실패" } } + } + _sideEffects.emit(SettingsCareerLevelSideEffect.NavigateBack) + } + } + + /** + * 프로필을 조회해 현재 개발 수준을 선택 상태로 표시한다. + * 실패하면 선택 없이 시작하고 원인을 로그로 남긴다. + */ + private fun loadCurrentCareerLevel() { + viewModelScope.launch { + getMemberProfile() + .onSuccess { profile -> + initialCareerLevel = profile.careerLevel + _uiState.value = SettingsCareerLevelUiState(selected = profile.careerLevel) + }.onFailure { error -> logger.e(throwable = error) { "개발 수준 조회 실패" } } + } + } +} diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt index 926060d7..01337f1c 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt @@ -15,6 +15,7 @@ import org.koin.compose.viewmodel.koinViewModel * @param onPolicyClick 서비스 약관 및 정책 링크를 열도록 요청하는 콜백 * @param onDeleteAccountClick 계정 삭제 안내 화면으로 이동하는 콜백 * @param onDevelopmentFieldClick 개발 분야 선택 화면으로 이동하는 콜백 + * @param onLearningLevelClick 개발 수준 선택 화면으로 이동하는 콜백 */ @Composable fun SettingsRoute( @@ -22,6 +23,7 @@ fun SettingsRoute( onPolicyClick: () -> Unit, onDeleteAccountClick: () -> Unit, onDevelopmentFieldClick: () -> Unit, + onLearningLevelClick: () -> Unit, ) { val viewModel = koinViewModel() val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -37,5 +39,6 @@ fun SettingsRoute( onPolicyClick = onPolicyClick, onDeleteAccountClick = onDeleteAccountClick, onDevelopmentFieldClick = onDevelopmentFieldClick, + onLearningLevelClick = onLearningLevelClick, ) } diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsScreen.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsScreen.kt index 0fcb7fc3..1965c191 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsScreen.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsScreen.kt @@ -60,6 +60,7 @@ import org.jetbrains.compose.resources.stringResource * @param onPolicyClick 서비스 약관 및 정책 링크를 열도록 요청하는 콜백 * @param onDeleteAccountClick 계정 삭제 안내 화면으로 이동하는 콜백 * @param onDevelopmentFieldClick 개발 분야 선택 화면으로 이동하는 콜백 + * @param onLearningLevelClick 개발 수준 선택 화면으로 이동하는 콜백 * @param modifier 화면의 크기와 배치를 지정할 수식자 */ @Composable @@ -70,6 +71,7 @@ fun SettingsScreen( onPolicyClick: () -> Unit, onDeleteAccountClick: () -> Unit, onDevelopmentFieldClick: () -> Unit, + onLearningLevelClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -106,6 +108,7 @@ fun SettingsScreen( icon = SettingsIconType.Level, label = stringResource(Res.string.settings_development_level), value = learningLevel, + onClick = onLearningLevelClick, ) } @@ -325,6 +328,7 @@ private fun SettingsScreenPreview() { onPolicyClick = {}, onDeleteAccountClick = {}, onDevelopmentFieldClick = {}, + onLearningLevelClick = {}, ) } } diff --git a/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelViewModelTest.kt b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelViewModelTest.kt new file mode 100644 index 00000000..1a923f17 --- /dev/null +++ b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelViewModelTest.kt @@ -0,0 +1,129 @@ +@file:Suppress("ktlint:standard:function-naming") + +package com.nexters.hytime.gitit.feature.my + +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.MemberProfile +import com.nexters.hytime.gitit.domain.model.Position +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase +import com.nexters.hytime.gitit.domain.usecase.UpdateCareerLevelUseCase +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** 개발 수준 선택 화면의 선택·저장 흐름을 검증한다. */ +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsCareerLevelViewModelTest { + private val dispatcher = StandardTestDispatcher() + + /** ViewModel의 Main dispatcher를 테스트 dispatcher로 교체한다. */ + @BeforeTest + fun setUp() { + Dispatchers.setMain(dispatcher) + } + + /** 테스트 이후 Main dispatcher를 복원한다. */ + @AfterTest + fun tearDown() { + Dispatchers.resetMain() + } + + /** 진입하면 프로필의 현재 개발 분야를 선택 상태로 표시한다. */ + @Test + fun init_프로필을조회하면_현재수준을선택한다() { + runTest(dispatcher) { + val repository = FakeMemberRepository(profileResult = Result.success(PROFILE)) + val viewModel = createViewModel(repository) + runCurrent() + + assertEquals(CareerLevel.JUNIOR, viewModel.uiState.value.selected) + } + } + + /** 선택을 바꾸고 뒤로가면 서버에 저장한 뒤 이전 화면으로 이동한다. */ + @Test + fun backClick_선택이바뀌면_저장하고이전화면으로이동한다() { + runTest(dispatcher) { + val repository = FakeMemberRepository(profileResult = Result.success(PROFILE)) + val viewModel = createViewModel(repository) + runCurrent() + val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } + + viewModel.onCareerLevelClick(CareerLevel.SENIOR) + viewModel.onBackClick() + runCurrent() + + assertEquals(CareerLevel.SENIOR, repository.updatedCareerLevel) + assertEquals(SettingsCareerLevelSideEffect.NavigateBack, sideEffect.await()) + } + } + + /** 선택이 그대로면 저장 요청 없이 이전 화면으로 이동한다. */ + @Test + fun backClick_선택이그대로면_저장하지않고이동한다() { + runTest(dispatcher) { + val repository = FakeMemberRepository(profileResult = Result.success(PROFILE)) + val viewModel = createViewModel(repository) + runCurrent() + val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } + + viewModel.onBackClick() + runCurrent() + + assertEquals(null, repository.updatedCareerLevel) + assertEquals(SettingsCareerLevelSideEffect.NavigateBack, sideEffect.await()) + } + } + + /** 저장이 실패해도 이전 화면으로 이동한다. */ + @Test + fun backClick_저장이실패해도_이전화면으로이동한다() { + runTest(dispatcher) { + val repository = + FakeMemberRepository( + profileResult = Result.success(PROFILE), + updateResult = Result.failure(IllegalStateException("네트워크 오류")), + ) + val viewModel = createViewModel(repository) + runCurrent() + val sideEffect = async(start = CoroutineStart.UNDISPATCHED) { viewModel.sideEffects.first() } + + viewModel.onCareerLevelClick(CareerLevel.MIDDLE) + viewModel.onBackClick() + runCurrent() + + assertEquals(SettingsCareerLevelSideEffect.NavigateBack, sideEffect.await()) + } + } + + private fun createViewModel(repository: FakeMemberRepository): SettingsCareerLevelViewModel = + SettingsCareerLevelViewModel( + getMemberProfile = GetMemberProfileUseCase(repository), + updateCareerLevel = UpdateCareerLevelUseCase(repository), + ) + + private companion object { + private val PROFILE = + MemberProfile( + name = "김이박", + email = "gitit@example.com", + position = Position.BACKEND, + careerLevel = CareerLevel.JUNIOR, + thisWeekSolvedCount = 0, + thisMonthSolvedCount = 0, + streakDays = 0, + weeklyChart = emptyList(), + ) + } +} diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index 08f0679d..d8cb444a 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -18,10 +18,12 @@ import com.nexters.hytime.gitit.domain.usecase.RegisterProjectUseCase import com.nexters.hytime.gitit.domain.usecase.SignInUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitChoiceAnswerUseCase import com.nexters.hytime.gitit.domain.usecase.SubmitEssayAnswerUseCase +import com.nexters.hytime.gitit.domain.usecase.UpdateCareerLevelUseCase import com.nexters.hytime.gitit.domain.usecase.UpdatePositionUseCase import com.nexters.hytime.gitit.feature.bookmark.BookmarkViewModel import com.nexters.hytime.gitit.feature.home.HomeViewModel import com.nexters.hytime.gitit.feature.my.MyViewModel +import com.nexters.hytime.gitit.feature.my.SettingsCareerLevelViewModel import com.nexters.hytime.gitit.feature.my.SettingsPositionViewModel import com.nexters.hytime.gitit.feature.my.SettingsViewModel import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailViewModel @@ -65,11 +67,13 @@ val appModule: Module = single { GetBookmarkedQuestionsUseCase(repository = get()) } single { BookmarkQuestionUseCase(repository = get()) } single { UpdatePositionUseCase(repository = get()) } + single { UpdateCareerLevelUseCase(repository = get()) } single { RegisterProjectUseCase(repository = get()) } viewModel { SplashViewModel(authRepository = get(), sessionStorage = get()) } viewModel { MyViewModel(getMemberProfile = get()) } viewModel { SettingsViewModel(getMemberProfile = get()) } viewModel { SettingsPositionViewModel(getMemberProfile = get(), updatePosition = get()) } + viewModel { SettingsCareerLevelViewModel(getMemberProfile = get(), updateCareerLevel = get()) } viewModel { HomeViewModel(getProjects = get()) } viewModel { BookmarkViewModel(getBookmarkedQuestions = get(), bookmarkQuestion = get()) } viewModel { ProjectListViewModel(getProjects = get(), deleteProject = get()) } diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt index 7d922c60..940c03d0 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppNavHost.kt @@ -31,6 +31,7 @@ import com.nexters.hytime.gitit.feature.bookmark.BookmarkRoute import com.nexters.hytime.gitit.feature.home.HomeRoute import com.nexters.hytime.gitit.feature.my.AccountDeleteRoute import com.nexters.hytime.gitit.feature.my.MyRoute +import com.nexters.hytime.gitit.feature.my.SettingsCareerLevelRoute import com.nexters.hytime.gitit.feature.my.SettingsPositionRoute import com.nexters.hytime.gitit.feature.my.SettingsRoute import com.nexters.hytime.gitit.feature.onboarding.OnboardingRoute @@ -75,6 +76,7 @@ internal val appRouteSavedStateConfiguration = subclass(AppRoute.My.serializer()) subclass(AppRoute.Settings.serializer()) subclass(AppRoute.SettingsPosition.serializer()) + subclass(AppRoute.SettingsCareerLevel.serializer()) subclass(AppRoute.AccountDelete.serializer()) subclass(AppRoute.LiquidGlassExample.serializer()) subclass(AppRoute.Onboarding.serializer()) @@ -118,6 +120,7 @@ internal fun AppRoute.navigationMotion(): AppNavigationMotion = AppRoute.LiquidGlassExample, is AppRoute.ProjectDetail, AppRoute.Settings, + AppRoute.SettingsCareerLevel, AppRoute.SettingsPosition, -> AppNavigationMotion.Horizontal @@ -281,6 +284,7 @@ fun AppNavHost() { onPolicyClick = { uriHandler.openUri(POLICY_URL) }, onDeleteAccountClick = { backStack.add(AppRoute.AccountDelete) }, onDevelopmentFieldClick = { backStack.add(AppRoute.SettingsPosition) }, + onLearningLevelClick = { backStack.add(AppRoute.SettingsCareerLevel) }, ) } entry(metadata = AppRoute.SettingsPosition.navigationMetadata()) { @@ -288,6 +292,11 @@ fun AppNavHost() { onNavigateBack = { backStack.removeLastOrNull() }, ) } + entry(metadata = AppRoute.SettingsCareerLevel.navigationMetadata()) { + SettingsCareerLevelRoute( + onNavigateBack = { backStack.removeLastOrNull() }, + ) + } entry(metadata = AppRoute.AccountDelete.navigationMetadata()) { AccountDeleteRoute( onBackClick = { backStack.removeLastOrNull() }, diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt index f9da99ba..b3fb3ecc 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/navigation/AppRoute.kt @@ -50,6 +50,10 @@ sealed interface AppRoute : NavKey { @Serializable data object SettingsPosition : AppRoute + /** 설정에서 개발 수준을 변경하는 선택 화면이다. */ + @Serializable + data object SettingsCareerLevel : AppRoute + /** 계정 삭제 전 주의사항을 안내하는 화면이다. */ @Serializable data object AccountDelete : AppRoute From deacce805a9d739bb841fa38a0122ac225c59cc7 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 16:00:22 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat:=20=ED=99=88=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=20=ED=97=A4=EB=8D=94=EC=97=90=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 홈 헤더의 이름·역할이 더미 기본값(김이박·Junior Developer)으로 남아 있어 서버 프로필이 화면에 노출되지 않던 것을 연동한다 - 역할 표기는 시안의 Junior Developer 패턴을 개발 수준별로 잇는다 (Entry·Junior·Middle·Senior Developer). 큐레이션 전이면 빈 문자열이다 - Refresh 인텐트가 프로필도 함께 다시 조회한다 - ktlintCheck, :feature:home:jvmTest, :shared:jvmTest, :androidApp:compileDebugKotlin 통과 --- .../hytime/gitit/feature/home/HomeContract.kt | 8 ++--- .../gitit/feature/home/HomeProjectMapper.kt | 17 +++++++++ .../gitit/feature/home/HomeViewModel.kt | 26 +++++++++++++- .../feature/home/FakeHomeMemberRepository.kt | 27 ++++++++++++++ .../gitit/feature/home/HomeScreenTest.kt | 36 +++++++++++++++++++ .../kotlin/com/nexters/hytime/gitit/Di.kt | 2 +- 6 files changed, 110 insertions(+), 6 deletions(-) create mode 100644 feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/FakeHomeMemberRepository.kt diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeContract.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeContract.kt index 52c7e533..1dceadc9 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeContract.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeContract.kt @@ -25,13 +25,13 @@ data class HomeLearningProject( /** * 홈 화면이 표시할 상태를 정의한다. * - * @property userName 상단 프로필에 표시할 사용자 이름 - * @property userRole 사용자 이름 아래에 표시할 역할 + * @property userName 상단 프로필에 표시할 사용자 이름. 프로필 조회 전이면 빈 문자열 + * @property userRole 사용자 이름 아래에 표시할 역할. 개발 수준에서 만들며 조회 전이면 빈 문자열 * @property learningProjects 이어서 학습할 프로젝트 목록. 비어 있으면 빈 상태를 표시한다 */ data class HomeUiState( - val userName: String = "김이박", - val userRole: String = "Junior Developer", + val userName: String = "", + val userRole: String = "", val learningProjects: List = emptyList(), ) diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt index 546a0865..b66893f7 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt @@ -1,5 +1,6 @@ package com.nexters.hytime.gitit.feature.home +import com.nexters.hytime.gitit.domain.model.CareerLevel import com.nexters.hytime.gitit.domain.model.ProjectSummary /** @@ -16,3 +17,19 @@ internal fun ProjectSummary.toLearningProject(): HomeLearningProject = description = currentSetTitle, progress = overallProgressPercent / 100f, ) + +/** + * 개발 수준을 홈 프로필 헤더의 역할 표기로 변환한다. + * + * 시안의 표기(Junior Developer) 패턴을 수준별로 잇는다. + * + * @return 역할 표기. 큐레이션 전이라 값이 없으면 빈 문자열 + */ +internal fun CareerLevel?.toRoleLabel(): String = + when (this) { + CareerLevel.ENTRY -> "Entry Developer" + CareerLevel.JUNIOR -> "Junior Developer" + CareerLevel.MIDDLE -> "Middle Developer" + CareerLevel.SENIOR -> "Senior Developer" + null -> "" + } diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt index a4395a05..a30b7e87 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt @@ -2,6 +2,7 @@ package com.nexters.hytime.gitit.feature.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableSharedFlow @@ -16,9 +17,11 @@ import kotlinx.coroutines.launch * 홈 화면의 상태와 사용자 의도를 관리한다. * * @property getProjects 이어서 학습할 프로젝트 목록을 조회하는 유스케이스 + * @property getMemberProfile 프로필 헤더에 표시할 이름·역할을 조회하는 유스케이스 */ class HomeViewModel( private val getProjects: GetProjectsUseCase, + private val getMemberProfile: GetMemberProfileUseCase, ) : ViewModel() { private val logger = gitItLogger() @@ -38,6 +41,7 @@ class HomeViewModel( init { loadProjects() + loadProfile() } /** @@ -47,7 +51,10 @@ class HomeViewModel( */ fun onIntent(intent: HomeIntent) { when (intent) { - HomeIntent.Refresh -> loadProjects() + HomeIntent.Refresh -> { + loadProjects() + loadProfile() + } HomeIntent.HomeTabClick -> Unit HomeIntent.LoadProjectClick -> emit(HomeSideEffect.NavigateToProjectLoad) HomeIntent.ViewAllProjectsClick -> emit(HomeSideEffect.NavigateToProjectList) @@ -63,6 +70,23 @@ class HomeViewModel( _sideEffects.tryEmit(sideEffect) } + /** + * 회원 프로필을 조회해 헤더의 이름과 역할을 채운다. + * 실패하면 이전 값을 유지하고 원인을 로그로 남긴다. + */ + private fun loadProfile() { + viewModelScope.launch { + getMemberProfile() + .onSuccess { profile -> + _uiState.value = + _uiState.value.copy( + userName = profile.name.orEmpty(), + userRole = profile.careerLevel.toRoleLabel(), + ) + }.onFailure { error -> logger.e(throwable = error) { "홈 프로필 조회 실패" } } + } + } + /** * 프로젝트 목록을 조회해 학습 카드를 채운다. * 실패하면 이전 목록을 유지하고 원인을 로그로 남긴다. diff --git a/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/FakeHomeMemberRepository.kt b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/FakeHomeMemberRepository.kt new file mode 100644 index 00000000..3e449028 --- /dev/null +++ b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/FakeHomeMemberRepository.kt @@ -0,0 +1,27 @@ +package com.nexters.hytime.gitit.feature.home + +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.DeviceInfo +import com.nexters.hytime.gitit.domain.model.MemberCuration +import com.nexters.hytime.gitit.domain.model.MemberProfile +import com.nexters.hytime.gitit.domain.model.Position +import com.nexters.hytime.gitit.domain.repository.MemberRepository + +/** + * 프로필 조회에 지정한 결과만 돌려주는 테스트용 회원 리포지토리다. + * + * @property profileResult 프로필 조회에 돌려줄 결과 + */ +internal class FakeHomeMemberRepository( + private val profileResult: Result, +) : MemberRepository { + override suspend fun getMemberProfile(): Result = profileResult + + override suspend fun registerDevice(deviceInfo: DeviceInfo): Result = error("호출되면 안 됩니다.") + + override suspend fun curateMember(curation: MemberCuration): Result = error("호출되면 안 됩니다.") + + override suspend fun updatePosition(position: Position): Result = error("호출되면 안 됩니다.") + + override suspend fun updateCareerLevel(careerLevel: CareerLevel): Result = error("호출되면 안 됩니다.") +} diff --git a/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt index f8e21ee1..9d4c1180 100644 --- a/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt +++ b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt @@ -1,8 +1,14 @@ +@file:Suppress("ktlint:standard:function-naming") + package com.nexters.hytime.gitit.feature.home import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.dp +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.MemberProfile +import com.nexters.hytime.gitit.domain.model.Position import com.nexters.hytime.gitit.domain.model.ProjectPage +import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase import com.nexters.hytime.gitit.domain.usecase.GetProjectsUseCase import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers @@ -11,6 +17,7 @@ import kotlinx.coroutines.async import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import kotlin.test.AfterTest @@ -88,11 +95,40 @@ class HomeScreenTest { assertEquals(HomeSideEffect.NavigateToProjectDetail("project-1"), sideEffect.await()) } + /** 프로필 조회가 성공하면 헤더의 이름과 역할을 서버 값으로 채운다. */ + @Test + fun init_프로필조회에성공하면_헤더이름과역할을채운다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + + assertEquals("김이박", viewModel.uiState.value.userName) + assertEquals("Junior Developer", viewModel.uiState.value.userRole) + } + } + private fun createViewModel(): HomeViewModel = HomeViewModel( getProjects = GetProjectsUseCase( FakeHomeProjectRepository(Result.success(ProjectPage(items = emptyList(), hasNext = false))), ), + getMemberProfile = + GetMemberProfileUseCase( + FakeHomeMemberRepository( + Result.success( + MemberProfile( + name = "김이박", + email = "gitit@example.com", + position = Position.BACKEND, + careerLevel = CareerLevel.JUNIOR, + thisWeekSolvedCount = 0, + thisMonthSolvedCount = 0, + streakDays = 0, + weeklyChart = emptyList(), + ), + ), + ), + ), ) } diff --git a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt index d8cb444a..680ab8ff 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -74,7 +74,7 @@ val appModule: Module = viewModel { SettingsViewModel(getMemberProfile = get()) } viewModel { SettingsPositionViewModel(getMemberProfile = get(), updatePosition = get()) } viewModel { SettingsCareerLevelViewModel(getMemberProfile = get(), updateCareerLevel = get()) } - viewModel { HomeViewModel(getProjects = get()) } + viewModel { HomeViewModel(getProjects = get(), getMemberProfile = get()) } viewModel { BookmarkViewModel(getBookmarkedQuestions = get(), bookmarkQuestion = get()) } viewModel { ProjectListViewModel(getProjects = get(), deleteProject = get()) } viewModel { params -> From c40addd3548107184f3f3d25bf55b97ae44be755 Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 16:11:42 +0900 Subject: [PATCH 13/14] =?UTF-8?q?fix:=20=EB=B0=B0=ED=8F=AC=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=9D=91=EB=8B=B5=20=EA=B3=84=EC=95=BD=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=20DTO=20=ED=95=84=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 참고한 Git-it-Server 저장소가 배포본(git-it.kr)보다 낡아 필드명이 어긋나 있었다 live OpenAPI 응답 예시와 대조해 세 곳을 고친다 - 프로젝트 목록: nextProblemId -> nextSetId + nextQuestionId 기존 이름으로는 항상 null이라 이어 풀 문제를 알 수 없었다 - 문제 북마크 응답: isBookmarked -> bookmarked 기존 이름으로는 서버가 적용한 상태가 항상 false로 읽혔다 - 북마크 목록: 응답에 있는 setId를 도메인까지 전달한다 - ktlintCheck, 전 모듈 테스트 131개 통과 --- .../hytime/gitit/data/dto/BookmarkQuestionRequest.kt | 4 ++-- .../gitit/data/dto/BookmarkedQuestionListResponse.kt | 2 ++ .../hytime/gitit/data/dto/ProjectListResponse.kt | 6 ++++-- .../hytime/gitit/data/mapping/ProjectMapping.kt | 4 +++- .../gitit/data/repository/ProjectRepositoryImpl.kt | 2 +- .../gitit/data/repository/ProjectRepositoryImplTest.kt | 10 ++++++---- .../hytime/gitit/domain/model/BookmarkedQuestions.kt | 2 ++ .../com/nexters/hytime/gitit/domain/model/Project.kt | 6 ++++-- .../gitit/feature/bookmark/BookmarkViewModelTest.kt | 1 + .../feature/projectlist/ProjectListViewModelTest.kt | 3 ++- 10 files changed, 27 insertions(+), 13 deletions(-) diff --git a/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/BookmarkQuestionRequest.kt b/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/BookmarkQuestionRequest.kt index ba78ed76..016ecf1c 100644 --- a/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/BookmarkQuestionRequest.kt +++ b/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/BookmarkQuestionRequest.kt @@ -15,9 +15,9 @@ internal data class BookmarkQuestionRequest( /** * 문제 북마크 설정 응답이다. * - * @property isBookmarked 서버에 적용된 북마크 상태 + * @property bookmarked 서버에 적용된 북마크 상태 */ @Serializable internal data class BookmarkQuestionResponse( - val isBookmarked: Boolean = false, + val bookmarked: Boolean = false, ) diff --git a/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/BookmarkedQuestionListResponse.kt b/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/BookmarkedQuestionListResponse.kt index e345aabf..8a592eae 100644 --- a/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/BookmarkedQuestionListResponse.kt +++ b/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/BookmarkedQuestionListResponse.kt @@ -33,6 +33,7 @@ internal data class AvailableProjectResponse( * * @property projectId 문제가 속한 프로젝트 식별자 * @property projectName 저장소 이름 + * @property setId 문제가 속한 학습 세트 식별자 * @property setLabel 세트 라벨 * @property problemNumber 세트 내 문제 번호 * @property questionId 문제 식별자 @@ -42,6 +43,7 @@ internal data class AvailableProjectResponse( internal data class BookmarkedQuestionResponse( val projectId: String, val projectName: String = "", + val setId: String = "", val setLabel: String = "", val problemNumber: Int = 0, val questionId: String, diff --git a/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/ProjectListResponse.kt b/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/ProjectListResponse.kt index ccbd60c3..bd764bab 100644 --- a/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/ProjectListResponse.kt +++ b/data/src/main/kotlin/com/nexters/hytime/gitit/data/dto/ProjectListResponse.kt @@ -23,7 +23,8 @@ internal data class ProjectListResponse( * @property techStack 기술 스택 * @property currentSetLabel 다음에 풀 문제가 속한 세트 라벨 * @property currentSetTitle 다음에 풀 문제가 속한 세트 제목 - * @property nextProblemId 이어서 풀 문제 식별자 + * @property nextSetId 이어서 풀 문제가 속한 학습 세트 식별자 + * @property nextQuestionId 이어서 풀 문제 식별자 * @property overallProgressPercent 프로젝트 전체 진행률(%) */ @Serializable @@ -34,6 +35,7 @@ internal data class ProjectItemResponse( val techStack: List = emptyList(), val currentSetLabel: String = "", val currentSetTitle: String = "", - val nextProblemId: String? = null, + val nextSetId: String? = null, + val nextQuestionId: String? = null, val overallProgressPercent: Int = 0, ) diff --git a/data/src/main/kotlin/com/nexters/hytime/gitit/data/mapping/ProjectMapping.kt b/data/src/main/kotlin/com/nexters/hytime/gitit/data/mapping/ProjectMapping.kt index 66cdc894..95228bfa 100644 --- a/data/src/main/kotlin/com/nexters/hytime/gitit/data/mapping/ProjectMapping.kt +++ b/data/src/main/kotlin/com/nexters/hytime/gitit/data/mapping/ProjectMapping.kt @@ -57,7 +57,8 @@ internal fun ProjectItemResponse.toDomain(): ProjectSummary = techStack = techStack, currentSetLabel = currentSetLabel, currentSetTitle = currentSetTitle, - nextProblemId = nextProblemId, + nextSetId = nextSetId, + nextQuestionId = nextQuestionId, overallProgressPercent = overallProgressPercent, ) @@ -233,6 +234,7 @@ internal fun BookmarkedQuestionResponse.toDomain(): BookmarkedQuestion = BookmarkedQuestion( projectId = projectId, projectName = projectName, + setId = setId, setLabel = setLabel, problemNumber = problemNumber, questionId = questionId, diff --git a/data/src/main/kotlin/com/nexters/hytime/gitit/data/repository/ProjectRepositoryImpl.kt b/data/src/main/kotlin/com/nexters/hytime/gitit/data/repository/ProjectRepositoryImpl.kt index e92abd93..bbb58ab3 100644 --- a/data/src/main/kotlin/com/nexters/hytime/gitit/data/repository/ProjectRepositoryImpl.kt +++ b/data/src/main/kotlin/com/nexters/hytime/gitit/data/repository/ProjectRepositoryImpl.kt @@ -146,7 +146,7 @@ class ProjectRepositoryImpl( "${questionPath(projectId, questionId)}/bookmark", BookmarkQuestionRequest(bookmarked), ).requireData("문제 북마크 응답이 올바르지 않습니다.") - .isBookmarked + .bookmarked } override suspend fun getBookmarkedQuestions(projectId: String?): Result = diff --git a/data/src/test/kotlin/com/nexters/hytime/gitit/data/repository/ProjectRepositoryImplTest.kt b/data/src/test/kotlin/com/nexters/hytime/gitit/data/repository/ProjectRepositoryImplTest.kt index a7b57ce5..dc9920a6 100644 --- a/data/src/test/kotlin/com/nexters/hytime/gitit/data/repository/ProjectRepositoryImplTest.kt +++ b/data/src/test/kotlin/com/nexters/hytime/gitit/data/repository/ProjectRepositoryImplTest.kt @@ -94,7 +94,8 @@ class ProjectRepositoryImplTest { assertEquals(listOf("react"), page.items.map { it.repositoryName }) assertEquals(listOf("TypeScript", "JavaScript"), page.items.first().techStack) assertEquals("Set 1", page.items.first().currentSetLabel) - assertEquals("q1", page.items.first().nextProblemId) + assertEquals("s1", page.items.first().nextSetId) + assertEquals("q1", page.items.first().nextQuestionId) assertEquals(40, page.items.first().overallProgressPercent) } @@ -282,7 +283,7 @@ class ProjectRepositoryImplTest { /** 북마크 설정이 서버가 적용한 상태를 돌려주는지 검증한다. */ @Test fun bookmarkQuestion_성공하면_적용된상태를반환한다() { - val networkClient = FakeNetworkClient("""{"success":true,"data":{"isBookmarked":true}}""") + val networkClient = FakeNetworkClient("""{"success":true,"data":{"bookmarked":true}}""") val bookmarked = runBlocking { ProjectRepositoryImpl(networkClient).bookmarkQuestion("p1", "q1", true) }.getOrThrow() @@ -328,7 +329,8 @@ class ProjectRepositoryImplTest { private const val PROJECT_LIST_RESPONSE = """{"success":true,"data":{"items":[{"projectId":"p1","repositoryName":"react",""" + """"repositoryImageUrl":"https://example.com/a.png","techStack":["TypeScript","JavaScript"],""" + - """"currentSetLabel":"Set 1","currentSetTitle":"라우팅","nextProblemId":"q1","overallProgressPercent":40}],""" + + """"currentSetLabel":"Set 1","currentSetTitle":"라우팅","nextSetId":"s1","nextQuestionId":"q1",""" + + """"overallProgressPercent":40}],""" + """"hasNext":true}}""" private const val PROJECT_DETAIL_RESPONSE = @@ -353,7 +355,7 @@ class ProjectRepositoryImplTest { private const val BOOKMARK_LIST_RESPONSE = """{"success":true,"data":{"totalCount":1,"availableProjects":[{"projectId":"p1","projectName":"react"}],""" + - """"bookmarks":[{"projectId":"p1","projectName":"react","setLabel":"Set 1","problemNumber":1,""" + + """"bookmarks":[{"projectId":"p1","projectName":"react","setId":"s1","setLabel":"Set 1","problemNumber":1,""" + """"questionId":"q1","question":"문제1"}]}}""" } } diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/model/BookmarkedQuestions.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/model/BookmarkedQuestions.kt index 7a69e0da..73650a1d 100644 --- a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/model/BookmarkedQuestions.kt +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/model/BookmarkedQuestions.kt @@ -29,6 +29,7 @@ data class AvailableProject( * * @property projectId 문제가 속한 프로젝트 식별자 * @property projectName 저장소 이름 + * @property setId 문제가 속한 학습 세트 식별자 * @property setLabel 세트 라벨 (예: `"Set 1"`) * @property problemNumber 세트 내 문제 번호. 1부터 시작한다 * @property questionId 문제 식별자 @@ -37,6 +38,7 @@ data class AvailableProject( data class BookmarkedQuestion( val projectId: String, val projectName: String, + val setId: String, val setLabel: String, val problemNumber: Int, val questionId: String, diff --git a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/model/Project.kt b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/model/Project.kt index 05842b48..d4c172a4 100644 --- a/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/model/Project.kt +++ b/domain/src/main/kotlin/com/nexters/hytime/gitit/domain/model/Project.kt @@ -20,7 +20,8 @@ data class ProjectPage( * @property techStack 저장소에서 추출한 기술 스택 * @property currentSetLabel 다음에 풀 문제가 속한 세트 라벨 (예: `"Set 1"`) * @property currentSetTitle 다음에 풀 문제가 속한 세트 제목 - * @property nextProblemId 이어서 풀 문제 식별자. 풀 문제가 없으면 `null` + * @property nextSetId 이어서 풀 문제가 속한 학습 세트 식별자. 풀 문제가 없으면 `null` + * @property nextQuestionId 이어서 풀 문제 식별자. 풀 문제가 없으면 `null` * @property overallProgressPercent 프로젝트 전체 진행률(%) */ data class ProjectSummary( @@ -30,7 +31,8 @@ data class ProjectSummary( val techStack: List, val currentSetLabel: String, val currentSetTitle: String, - val nextProblemId: String?, + val nextSetId: String?, + val nextQuestionId: String?, val overallProgressPercent: Int, ) diff --git a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt index 03754c69..22836ca9 100644 --- a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt +++ b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt @@ -131,6 +131,7 @@ class BookmarkViewModelTest { DomainBookmarkedQuestion( projectId = "p1", projectName = "flask", + setId = "s1", setLabel = "Set 1", problemNumber = 3, questionId = "q1", diff --git a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt index 11d36f5b..de5a457e 100644 --- a/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt +++ b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/ProjectListViewModelTest.kt @@ -205,7 +205,8 @@ class ProjectListViewModelTest { techStack = listOf("TypeScript", "JavaScript"), currentSetLabel = "Set 1", currentSetTitle = "라우팅 흐름 따라가기", - nextProblemId = "q1", + nextSetId = "s1", + nextQuestionId = "q1", overallProgressPercent = 40, ) }, From d6826c519e2ce38b6d3a1021ce67d8764a4740ee Mon Sep 17 00:00:00 2001 From: Been Date: Mon, 17 Aug 2026 17:26:51 +0900 Subject: [PATCH 14/14] =?UTF-8?q?refactor:=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=AC=B8=EA=B5=AC=EB=A5=BC=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=20=EB=A6=AC=EC=86=8C=EC=8A=A4=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 연동 과정에서 마이·홈·저장한 문제 화면의 표시 문구가 Kotlin 코드에 하드코딩돼 있던 것을 각 모듈 strings.xml로 옮긴다 - 문구 해석은 Composable에서만 한다. Compose Resources의 getString은 데스크톱에서 skiko 네이티브를 요구해 ViewModel에서 부르면 jvmTest가 깨진다 - 그래서 UiState는 완성된 문구 대신 값을 담는다 MyProfile.position/careerLevel, MyStudyStat(type, count), SettingsUiState(position, careerLevel), HomeUiState.careerLevel, BookmarkedQuestion(projectName, setLabel, problemNumber) - BookmarkFilter.label은 전체 필터일 때 null이고 화면이 고정 문구를 넣는다 - 개발 분야·수준 라벨은 toDisplayLabelResource() 한 곳에만 두어 마이·설정·선택 화면이 같은 문구를 공유한다 - 구분자(" · "), 스타 수 k 표기, 문제 출처 라벨, 로그 메시지, 프리뷰 더미는 번역 대상이 아니라 그대로 둔다 - ktlintCheck, :feature:my/home/bookmark:allTests, :shared:jvmTest, :androidApp:compileDebugKotlin, :desktopApp:compileKotlin 통과 Co-Authored-By: Claude Fable 5 --- .../composeResources/values/strings.xml | 4 ++ .../feature/bookmark/BookmarkContract.kt | 14 +++-- .../gitit/feature/bookmark/BookmarkMapper.kt | 7 ++- .../gitit/feature/bookmark/BookmarkScreen.kt | 20 +++++-- .../feature/bookmark/BookmarkViewModelTest.kt | 6 ++- feature/home/build.gradle.kts | 1 + .../composeResources/values/strings.xml | 6 +++ .../hytime/gitit/feature/home/HomeContract.kt | 5 +- .../gitit/feature/home/HomeProjectMapper.kt | 21 +++++--- .../hytime/gitit/feature/home/HomeScreen.kt | 3 +- .../gitit/feature/home/HomeViewModel.kt | 2 +- .../gitit/feature/home/HomeScreenTest.kt | 2 +- .../composeResources/values/strings.xml | 17 ++++++ .../gitit/feature/my/MemberProfileMapper.kt | 52 +++++++++++-------- .../hytime/gitit/feature/my/MyContract.kt | 33 +++++++++--- .../hytime/gitit/feature/my/MyScreen.kt | 48 +++++++++++++---- .../feature/my/SettingsCareerLevelRoute.kt | 26 ++++++---- .../gitit/feature/my/SettingsPositionRoute.kt | 2 +- .../hytime/gitit/feature/my/SettingsRoute.kt | 5 +- .../gitit/feature/my/SettingsViewModel.kt | 16 +++--- .../gitit/feature/my/MyViewModelTest.kt | 18 ++++--- 21 files changed, 218 insertions(+), 90 deletions(-) create mode 100644 feature/bookmark/src/commonMain/composeResources/values/strings.xml create mode 100644 feature/home/src/commonMain/composeResources/values/strings.xml diff --git a/feature/bookmark/src/commonMain/composeResources/values/strings.xml b/feature/bookmark/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000..498ac8cc --- /dev/null +++ b/feature/bookmark/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,4 @@ + + 전체 + %1$s · %2$s · 문제 %3$d + diff --git a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkContract.kt b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkContract.kt index 0ec88ef6..11b6ce34 100644 --- a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkContract.kt +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkContract.kt @@ -19,25 +19,31 @@ data class BookmarkUiState( * 저장한 문제 필터 항목이다. * * @property id 필터 식별자 - * @property label 화면에 표시할 필터 이름 + * @property label 화면에 표시할 프로젝트 이름. 전체 필터면 null이며 화면이 고정 문구를 표시한다 */ data class BookmarkFilter( val id: String, - val label: String, + val label: String?, ) /** * 저장한 문제 카드 한 개를 표현한다. * + * 카드 상단의 메타 문구는 화면이 [projectName]·[setLabel]·[problemNumber]로 만든다. + * * @property id 문제 식별자 * @property projectId 문제가 속한 프로젝트 식별자 - * @property meta 프로젝트·세트·문제 번호 정보 + * @property projectName 문제가 속한 프로젝트 이름 + * @property setLabel 문제가 속한 학습 세트 라벨 + * @property problemNumber 세트 안에서의 문제 번호 * @property title 문제 제목 */ data class BookmarkedQuestion( val id: String, val projectId: String = "", - val meta: String, + val projectName: String, + val setLabel: String, + val problemNumber: Int, val title: String, ) diff --git a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt index c267a4a2..39099b66 100644 --- a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt @@ -9,6 +9,7 @@ internal const val BOOKMARK_FILTER_ALL_ID = "all" * 북마크 도메인 모델을 저장한 문제 화면 상태로 변환한다. * * 필터는 전체 항목 뒤에 북마크가 있는 프로젝트들을 잇고, 선택 중인 필터는 유지한다. + * 표시 문구는 화면이 문자열 리소스에서 만들므로 여기서는 값만 옮긴다. * * @param selectedFilterId 유지할 선택 필터 식별자 * @return 필터·문제 목록이 채워진 화면 상태 @@ -16,7 +17,7 @@ internal const val BOOKMARK_FILTER_ALL_ID = "all" internal fun BookmarkedQuestions.toUiState(selectedFilterId: String): BookmarkUiState = BookmarkUiState( filters = - listOf(BookmarkFilter(id = BOOKMARK_FILTER_ALL_ID, label = "전체")) + + listOf(BookmarkFilter(id = BOOKMARK_FILTER_ALL_ID, label = null)) + availableProjects.map { project -> BookmarkFilter(id = project.projectId, label = project.projectName) }, selectedFilterId = selectedFilterId, questions = @@ -24,7 +25,9 @@ internal fun BookmarkedQuestions.toUiState(selectedFilterId: String): BookmarkUi BookmarkedQuestion( id = bookmark.questionId, projectId = bookmark.projectId, - meta = "${bookmark.projectName} · ${bookmark.setLabel} · 문제 ${bookmark.problemNumber}", + projectName = bookmark.projectName, + setLabel = bookmark.setLabel, + problemNumber = bookmark.problemNumber, title = bookmark.question, ) }, diff --git a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkScreen.kt b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkScreen.kt index dc69b3c3..cb0ead7e 100644 --- a/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkScreen.kt +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkScreen.kt @@ -35,6 +35,10 @@ import com.nexters.hytime.gitit.designsystem.navigation.GitItMainNavBar import com.nexters.hytime.gitit.designsystem.navigation.GitItMainNavDestination import com.nexters.hytime.gitit.designsystem.navigation.gitItMainNavSky import com.nexters.hytime.gitit.designsystem.navigation.rememberGitItMainNavSky +import git_it_kmp.feature.bookmark.generated.resources.Res +import git_it_kmp.feature.bookmark.generated.resources.bookmark_filter_all +import git_it_kmp.feature.bookmark.generated.resources.bookmark_question_meta +import org.jetbrains.compose.resources.stringResource /** * 저장한 문제 화면의 순수 UI 영역이다. @@ -210,7 +214,7 @@ private fun BookmarkFilterChip( contentAlignment = Alignment.Center, ) { Text( - text = filter.label, + text = filter.label ?: stringResource(Res.string.bookmark_filter_all), color = if (selected) GitItTheme.colors.grey700 else GitItTheme.colors.grey300, style = GitItTheme.typography.body2, maxLines = 1, @@ -245,7 +249,13 @@ private fun BookmarkedQuestionCard( .padding(start = 16.dp, top = 17.dp, end = 16.dp, bottom = 14.dp), ) { Text( - text = question.meta, + text = + stringResource( + Res.string.bookmark_question_meta, + question.projectName, + question.setLabel, + question.problemNumber, + ), color = GitItTheme.colors.grey400, style = GitItTheme.typography.caption1, maxLines = 1, @@ -340,7 +350,7 @@ private fun BookmarkScreenPreview() { BookmarkUiState( filters = listOf( - BookmarkFilter(id = "all", label = "전체"), + BookmarkFilter(id = "all", label = null), BookmarkFilter(id = "flask", label = "Flask"), BookmarkFilter(id = "android", label = "Now in Android"), ), @@ -349,7 +359,9 @@ private fun BookmarkScreenPreview() { List(4) { index -> BookmarkedQuestion( id = "bookmark-$index", - meta = "Android · Set2 · 문제 1", + projectName = "Android", + setLabel = "Set2", + problemNumber = 1, title = "sansio/blueprints.py에 정의된 BlueprintSetupState 클래스는 어떤 목적을 가진 개체인가?", ) }, diff --git a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt index 22836ca9..e2ae37b9 100644 --- a/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt +++ b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkViewModelTest.kt @@ -47,10 +47,12 @@ class BookmarkViewModelTest { val state = viewModel.uiState.value assertEquals(null, repository.requestedProjectId) assertEquals(listOf("all", "p1"), state.filters.map(BookmarkFilter::id)) - assertEquals(listOf("전체", "flask"), state.filters.map(BookmarkFilter::label)) + assertEquals(listOf(null, "flask"), state.filters.map(BookmarkFilter::label)) assertEquals("all", state.selectedFilterId) assertEquals(listOf("q1"), state.questions.map(BookmarkedQuestion::id)) - assertEquals("flask · Set 1 · 문제 3", state.questions.first().meta) + assertEquals("flask", state.questions.first().projectName) + assertEquals("Set 1", state.questions.first().setLabel) + assertEquals(3, state.questions.first().problemNumber) assertEquals("블루프린트의 목적은?", state.questions.first().title) } } diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 98fade9d..1e9b7297 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -12,6 +12,7 @@ kotlin { implementation(libs.koin.compose.viewmodel) implementation(libs.compose.material3) + implementation(libs.compose.components.resources) implementation(libs.androidx.lifecycle.viewmodelCompose) implementation(libs.androidx.lifecycle.runtimeCompose) } diff --git a/feature/home/src/commonMain/composeResources/values/strings.xml b/feature/home/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000..c2ea81ed --- /dev/null +++ b/feature/home/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,6 @@ + + Entry Developer + Junior Developer + Middle Developer + Senior Developer + diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeContract.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeContract.kt index 1dceadc9..3016f1df 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeContract.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeContract.kt @@ -1,6 +1,7 @@ package com.nexters.hytime.gitit.feature.home import androidx.compose.runtime.Immutable +import com.nexters.hytime.gitit.domain.model.CareerLevel /** * 홈에서 이어서 학습할 프로젝트 카드 정보다. @@ -26,12 +27,12 @@ data class HomeLearningProject( * 홈 화면이 표시할 상태를 정의한다. * * @property userName 상단 프로필에 표시할 사용자 이름. 프로필 조회 전이면 빈 문자열 - * @property userRole 사용자 이름 아래에 표시할 역할. 개발 수준에서 만들며 조회 전이면 빈 문자열 + * @property careerLevel 이름 아래 역할 표기를 만들 개발 수준. 조회 전이거나 큐레이션 전이면 null * @property learningProjects 이어서 학습할 프로젝트 목록. 비어 있으면 빈 상태를 표시한다 */ data class HomeUiState( val userName: String = "", - val userRole: String = "", + val careerLevel: CareerLevel? = null, val learningProjects: List = emptyList(), ) diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt index b66893f7..2450b267 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt @@ -2,6 +2,12 @@ package com.nexters.hytime.gitit.feature.home import com.nexters.hytime.gitit.domain.model.CareerLevel import com.nexters.hytime.gitit.domain.model.ProjectSummary +import git_it_kmp.feature.home.generated.resources.Res +import git_it_kmp.feature.home.generated.resources.home_role_entry +import git_it_kmp.feature.home.generated.resources.home_role_junior +import git_it_kmp.feature.home.generated.resources.home_role_middle +import git_it_kmp.feature.home.generated.resources.home_role_senior +import org.jetbrains.compose.resources.StringResource /** * 프로젝트 요약 도메인 모델을 홈 학습 카드 모델로 변환한다. @@ -19,17 +25,16 @@ internal fun ProjectSummary.toLearningProject(): HomeLearningProject = ) /** - * 개발 수준을 홈 프로필 헤더의 역할 표기로 변환한다. + * 개발 수준을 홈 프로필 헤더의 역할 표기 문구 리소스로 변환한다. * * 시안의 표기(Junior Developer) 패턴을 수준별로 잇는다. * - * @return 역할 표기. 큐레이션 전이라 값이 없으면 빈 문자열 + * @return 이름 아래에 표시할 역할 표기 리소스 */ -internal fun CareerLevel?.toRoleLabel(): String = +internal fun CareerLevel.toRoleLabelResource(): StringResource = when (this) { - CareerLevel.ENTRY -> "Entry Developer" - CareerLevel.JUNIOR -> "Junior Developer" - CareerLevel.MIDDLE -> "Middle Developer" - CareerLevel.SENIOR -> "Senior Developer" - null -> "" + CareerLevel.ENTRY -> Res.string.home_role_entry + CareerLevel.JUNIOR -> Res.string.home_role_junior + CareerLevel.MIDDLE -> Res.string.home_role_middle + CareerLevel.SENIOR -> Res.string.home_role_senior } diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreen.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreen.kt index 73e84e97..02716408 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreen.kt @@ -62,6 +62,7 @@ import com.nexters.hytime.gitit.designsystem.navigation.gitItMainNavSky import com.nexters.hytime.gitit.designsystem.navigation.rememberGitItMainNavSky import com.nexters.hytime.gitit.designsystem.toolbar.GitItTopBar import com.nexters.hytime.gitit.designsystem.toolbar.GitItTopBarType +import org.jetbrains.compose.resources.stringResource import kotlin.math.absoluteValue /** @@ -152,7 +153,7 @@ private fun HomeProfile( GitItTopBar( type = GitItTopBarType.InlineUser, userName = uiState.userName, - userSubtitle = uiState.userRole, + userSubtitle = uiState.careerLevel?.let { stringResource(it.toRoleLabelResource()) }.orEmpty(), userAvatar = { Box( modifier = diff --git a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt index a30b7e87..770ccfae 100644 --- a/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeViewModel.kt @@ -81,7 +81,7 @@ class HomeViewModel( _uiState.value = _uiState.value.copy( userName = profile.name.orEmpty(), - userRole = profile.careerLevel.toRoleLabel(), + careerLevel = profile.careerLevel, ) }.onFailure { error -> logger.e(throwable = error) { "홈 프로필 조회 실패" } } } diff --git a/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt index 9d4c1180..91936e44 100644 --- a/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt +++ b/feature/home/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/home/HomeScreenTest.kt @@ -103,7 +103,7 @@ class HomeScreenTest { runCurrent() assertEquals("김이박", viewModel.uiState.value.userName) - assertEquals("Junior Developer", viewModel.uiState.value.userRole) + assertEquals(CareerLevel.JUNIOR, viewModel.uiState.value.careerLevel) } } diff --git a/feature/my/src/commonMain/composeResources/values/strings.xml b/feature/my/src/commonMain/composeResources/values/strings.xml index 18c39efe..e10eac9f 100644 --- a/feature/my/src/commonMain/composeResources/values/strings.xml +++ b/feature/my/src/commonMain/composeResources/values/strings.xml @@ -3,10 +3,27 @@ 이번 주 첫 문제를 풀어볼까요? 이번 주도 차근차근 풀고 있어요 이번 주 학습을 하루도 놓치지 않았어요 + 이번 주 + 이번 달 + 연속 학습 + %1$d문제 + %1$d일 + Front-end + Back-end + iOS + Android + 입문 + 주니어 + 미들 + 시니어 설정 학습 설정 개발 분야 개발 수준 + 프로젝트 코드를 처음 살펴봐요. + 작은 기능 단위로 코드를 이해할 수 있어요. + 프로젝트 구조와 흐름을 함께 살펴봐요. + 설계 의도와 변경 영향을 분석할 수 있어요. 알림 세트 생성 완료 알림 켜짐 diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt index d1ab3213..f42f0ce0 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt @@ -4,10 +4,22 @@ import com.nexters.hytime.gitit.domain.model.CareerLevel import com.nexters.hytime.gitit.domain.model.DaySolvedCount import com.nexters.hytime.gitit.domain.model.MemberProfile import com.nexters.hytime.gitit.domain.model.Position +import git_it_kmp.feature.my.generated.resources.Res +import git_it_kmp.feature.my.generated.resources.my_career_level_entry +import git_it_kmp.feature.my.generated.resources.my_career_level_junior +import git_it_kmp.feature.my.generated.resources.my_career_level_middle +import git_it_kmp.feature.my.generated.resources.my_career_level_senior +import git_it_kmp.feature.my.generated.resources.my_position_android +import git_it_kmp.feature.my.generated.resources.my_position_backend +import git_it_kmp.feature.my.generated.resources.my_position_frontend +import git_it_kmp.feature.my.generated.resources.my_position_ios +import org.jetbrains.compose.resources.StringResource /** * 회원 프로필 도메인 모델을 마이 화면 상태로 변환한다. * + * 표시 문구는 화면이 문자열 리소스에서 만들므로 여기서는 값만 옮긴다. + * * @return 프로필·학습 통계·주간 학습량이 채워진 화면 상태 */ internal fun MemberProfile.toUiState(): MyUiState = @@ -16,14 +28,14 @@ internal fun MemberProfile.toUiState(): MyUiState = MyProfile( name = name.orEmpty(), email = email.orEmpty(), - developmentField = position.toDisplayLabel(), - learningLevel = careerLevel.toDisplayLabel(), + position = position, + careerLevel = careerLevel, ), stats = listOf( - MyStudyStat(label = "이번 주", value = "${thisWeekSolvedCount}문제"), - MyStudyStat(label = "이번 달", value = "${thisMonthSolvedCount}문제"), - MyStudyStat(label = "연속 학습", value = "${streakDays}일"), + MyStudyStat(type = MyStudyStatType.THIS_WEEK, count = thisWeekSolvedCount), + MyStudyStat(type = MyStudyStatType.THIS_MONTH, count = thisMonthSolvedCount), + MyStudyStat(type = MyStudyStatType.STREAK, count = streakDays), ), weeklyStudy = weeklyChart.map(DaySolvedCount::toWeeklyStudy), ) @@ -40,29 +52,27 @@ private fun DaySolvedCount.toWeeklyStudy(): MyWeeklyStudy = ) /** - * 개발 분야를 화면 표기 라벨로 변환한다. + * 개발 분야 표기에 쓸 문자열 리소스를 찾는다. * - * @return 표기 라벨. 큐레이션 전이라 값이 없으면 빈 문자열 + * @return 화면에 표시할 표기 문구 리소스 */ -internal fun Position?.toDisplayLabel(): String = +internal fun Position.toDisplayLabelResource(): StringResource = when (this) { - Position.BACKEND -> "Back-end" - Position.FRONTEND -> "Front-end" - Position.IOS -> "iOS" - Position.ANDROID -> "Android" - null -> "" + Position.BACKEND -> Res.string.my_position_backend + Position.FRONTEND -> Res.string.my_position_frontend + Position.IOS -> Res.string.my_position_ios + Position.ANDROID -> Res.string.my_position_android } /** - * 개발 수준을 화면 표기 라벨로 변환한다. + * 개발 수준 표기에 쓸 문자열 리소스를 찾는다. * - * @return 표기 라벨. 큐레이션 전이라 값이 없으면 빈 문자열 + * @return 화면에 표시할 표기 문구 리소스 */ -internal fun CareerLevel?.toDisplayLabel(): String = +internal fun CareerLevel.toDisplayLabelResource(): StringResource = when (this) { - CareerLevel.ENTRY -> "입문" - CareerLevel.JUNIOR -> "주니어" - CareerLevel.MIDDLE -> "미들" - CareerLevel.SENIOR -> "시니어" - null -> "" + CareerLevel.ENTRY -> Res.string.my_career_level_entry + CareerLevel.JUNIOR -> Res.string.my_career_level_junior + CareerLevel.MIDDLE -> Res.string.my_career_level_middle + CareerLevel.SENIOR -> Res.string.my_career_level_senior } diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyContract.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyContract.kt index e54517b9..ee6a3bbd 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyContract.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyContract.kt @@ -1,5 +1,8 @@ package com.nexters.hytime.gitit.feature.my +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.Position + /** * 마이 화면의 단일 UI 상태다. * @@ -18,27 +21,41 @@ data class MyUiState( * * @property name 화면에 표시할 사용자 이름 * @property email 사용자 계정 이메일 - * @property developmentField 사용자가 선택한 개발 분야 - * @property learningLevel 사용자가 선택한 학습 수준 + * @property position 사용자가 선택한 개발 분야. 큐레이션 전이면 null + * @property careerLevel 사용자가 선택한 학습 수준. 큐레이션 전이면 null */ data class MyProfile( val name: String = "", val email: String = "", - val developmentField: String = "", - val learningLevel: String = "", + val position: Position? = null, + val careerLevel: CareerLevel? = null, ) /** * 학습 현황 요약 항목이다. * - * @property label 항목 이름 - * @property value 항목 값 + * 항목 이름과 값의 단위 표기는 [type]에 맞춰 화면이 만든다. + * + * @property type 항목 종류 + * @property count 항목이 나타내는 수치 */ data class MyStudyStat( - val label: String, - val value: String, + val type: MyStudyStatType, + val count: Int, ) +/** 학습 현황 요약 항목의 종류다. */ +enum class MyStudyStatType { + /** 이번 주에 푼 문제 수. */ + THIS_WEEK, + + /** 이번 달에 푼 문제 수. */ + THIS_MONTH, + + /** 연속으로 학습한 일수. */ + STREAK, +} + /** * 주간 학습량 막대 한 개를 표현한다. * diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyScreen.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyScreen.kt index c990cb14..583f6d89 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyScreen.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MyScreen.kt @@ -36,10 +36,17 @@ import com.nexters.hytime.gitit.designsystem.navigation.GitItMainNavBar import com.nexters.hytime.gitit.designsystem.navigation.GitItMainNavDestination import com.nexters.hytime.gitit.designsystem.navigation.gitItMainNavSky import com.nexters.hytime.gitit.designsystem.navigation.rememberGitItMainNavSky +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.Position import git_it_kmp.feature.my.generated.resources.Res import git_it_kmp.feature.my.generated.resources.my_default_avatar import git_it_kmp.feature.my.generated.resources.my_settings import git_it_kmp.feature.my.generated.resources.my_settings_content_description +import git_it_kmp.feature.my.generated.resources.my_stat_solved_count +import git_it_kmp.feature.my.generated.resources.my_stat_streak +import git_it_kmp.feature.my.generated.resources.my_stat_streak_days +import git_it_kmp.feature.my.generated.resources.my_stat_this_month +import git_it_kmp.feature.my.generated.resources.my_stat_this_week import git_it_kmp.feature.my.generated.resources.my_weekly_study_complete import git_it_kmp.feature.my.generated.resources.my_weekly_study_empty import git_it_kmp.feature.my.generated.resources.my_weekly_study_in_progress @@ -187,11 +194,11 @@ private fun MyProfileHeader( Spacer(Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { MyProfileTag( - text = profile.developmentField, + text = profile.position?.let { stringResource(it.toDisplayLabelResource()) }.orEmpty(), backgroundColor = GitItTheme.colors.blue400, ) MyProfileTag( - text = profile.learningLevel, + text = profile.careerLevel?.let { stringResource(it.toDisplayLabelResource()) }.orEmpty(), backgroundColor = GitItTheme.colors.grey500, ) } @@ -291,14 +298,14 @@ private fun MyStatItem( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = stat.label, + text = stringResource(stat.type.toLabelResource()), color = GitItTheme.colors.grey300, style = GitItTheme.typography.caption2, textAlign = TextAlign.Center, ) Spacer(Modifier.height(4.dp)) Text( - text = stat.value, + text = stringResource(stat.type.toCountResource(), stat.count), color = GitItTheme.colors.blue100, style = GitItTheme.typography.subtitle3, textAlign = TextAlign.Center, @@ -450,6 +457,29 @@ internal fun weeklyStudyMessageResource(items: List): StringResou else -> Res.string.my_weekly_study_in_progress } +/** + * 학습 현황 항목의 이름 문구 리소스를 찾는다. + * + * @return 수치 위에 표시할 항목 이름 리소스 + */ +private fun MyStudyStatType.toLabelResource(): StringResource = + when (this) { + MyStudyStatType.THIS_WEEK -> Res.string.my_stat_this_week + MyStudyStatType.THIS_MONTH -> Res.string.my_stat_this_month + MyStudyStatType.STREAK -> Res.string.my_stat_streak + } + +/** + * 학습 현황 항목의 수치를 단위와 함께 표시할 문구 리소스를 찾는다. + * + * @return 수치 하나를 인자로 받는 문구 리소스 + */ +private fun MyStudyStatType.toCountResource(): StringResource = + when (this) { + MyStudyStatType.THIS_WEEK, MyStudyStatType.THIS_MONTH -> Res.string.my_stat_solved_count + MyStudyStatType.STREAK -> Res.string.my_stat_streak_days + } + @Preview @Composable private fun MyScreenPreview() { @@ -461,14 +491,14 @@ private fun MyScreenPreview() { MyProfile( name = "김이박", email = "kimlee@github.io", - developmentField = "Back-end", - learningLevel = "입문", + position = Position.BACKEND, + careerLevel = CareerLevel.ENTRY, ), stats = listOf( - MyStudyStat(label = "이번 주", value = "13문제"), - MyStudyStat(label = "이번 달", value = "47문제"), - MyStudyStat(label = "연속 학습", value = "7일"), + MyStudyStat(type = MyStudyStatType.THIS_WEEK, count = 13), + MyStudyStat(type = MyStudyStatType.THIS_MONTH, count = 47), + MyStudyStat(type = MyStudyStatType.STREAK, count = 7), ), weeklyStudy = listOf( diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt index 5a45dea7..be78e1c3 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt @@ -6,23 +6,27 @@ import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nexters.hytime.gitit.domain.model.CareerLevel import git_it_kmp.feature.my.generated.resources.Res +import git_it_kmp.feature.my.generated.resources.settings_career_level_entry_description +import git_it_kmp.feature.my.generated.resources.settings_career_level_junior_description +import git_it_kmp.feature.my.generated.resources.settings_career_level_middle_description +import git_it_kmp.feature.my.generated.resources.settings_career_level_senior_description import git_it_kmp.feature.my.generated.resources.settings_development_level import kotlinx.coroutines.flow.collectLatest +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel /** - * 개발 수준 카드에 표시할 수준별 설명이다. Figma 시안의 문구를 그대로 쓴다. + * 개발 수준 카드에 표시할 수준별 설명 리소스다. Figma 시안의 문구를 그대로 쓴다. * - * @param careerLevel 설명을 찾을 개발 수준 - * @return 카드 제목 아래에 표시할 한 줄 설명 + * @return 카드 제목 아래에 표시할 한 줄 설명 리소스 */ -private fun careerLevelDescription(careerLevel: CareerLevel): String = - when (careerLevel) { - CareerLevel.ENTRY -> "프로젝트 코드를 처음 살펴봐요." - CareerLevel.JUNIOR -> "작은 기능 단위로 코드를 이해할 수 있어요." - CareerLevel.MIDDLE -> "프로젝트 구조와 흐름을 함께 살펴봐요." - CareerLevel.SENIOR -> "설계 의도와 변경 영향을 분석할 수 있어요." +private fun CareerLevel.toDescriptionResource(): StringResource = + when (this) { + CareerLevel.ENTRY -> Res.string.settings_career_level_entry_description + CareerLevel.JUNIOR -> Res.string.settings_career_level_junior_description + CareerLevel.MIDDLE -> Res.string.settings_career_level_middle_description + CareerLevel.SENIOR -> Res.string.settings_career_level_senior_description } /** @@ -51,8 +55,8 @@ fun SettingsCareerLevelRoute(onNavigateBack: () -> Unit) { CareerLevel.entries.map { careerLevel -> SettingsSelectionOption( id = careerLevel.name, - title = careerLevel.toDisplayLabel(), - description = careerLevelDescription(careerLevel), + title = stringResource(careerLevel.toDisplayLabelResource()), + description = stringResource(careerLevel.toDescriptionResource()), ) }, selectedOptionId = uiState.selected?.name, diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt index 89b09e55..3006d04c 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt @@ -44,7 +44,7 @@ fun SettingsPositionRoute(onNavigateBack: () -> Unit) { title = stringResource(Res.string.settings_development_field), options = positionOptions.map { position -> - SettingsSelectionOption(id = position.name, title = position.toDisplayLabel()) + SettingsSelectionOption(id = position.name, title = stringResource(position.toDisplayLabelResource())) }, selectedOptionId = uiState.selected?.name, onOptionClick = { option -> viewModel.onPositionClick(Position.valueOf(option.id)) }, diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt index 01337f1c..c89e103d 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel /** @@ -33,8 +34,8 @@ fun SettingsRoute( } SettingsScreen( - developmentField = uiState.developmentField, - learningLevel = uiState.learningLevel, + developmentField = uiState.position?.let { stringResource(it.toDisplayLabelResource()) }.orEmpty(), + learningLevel = uiState.careerLevel?.let { stringResource(it.toDisplayLabelResource()) }.orEmpty(), onBackClick = onBackClick, onPolicyClick = onPolicyClick, onDeleteAccountClick = onDeleteAccountClick, diff --git a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt index ee12a13a..f220be73 100644 --- a/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt @@ -2,6 +2,8 @@ package com.nexters.hytime.gitit.feature.my import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.nexters.hytime.gitit.domain.model.CareerLevel +import com.nexters.hytime.gitit.domain.model.Position import com.nexters.hytime.gitit.domain.usecase.GetMemberProfileUseCase import com.nexters.hytime.gitit.logging.gitItLogger import kotlinx.coroutines.flow.MutableStateFlow @@ -12,12 +14,14 @@ import kotlinx.coroutines.launch /** * 설정 화면의 단일 UI 상태다. * - * @property developmentField 현재 선택한 개발 분야 표기 라벨. 큐레이션 전이면 빈 문자열 - * @property learningLevel 현재 선택한 개발 수준 표기 라벨. 큐레이션 전이면 빈 문자열 + * 표기 라벨은 화면이 문자열 리소스에서 만들므로 여기서는 선택 값만 들고 있다. + * + * @property position 현재 선택한 개발 분야. 큐레이션 전이면 null + * @property careerLevel 현재 선택한 개발 수준. 큐레이션 전이면 null */ data class SettingsUiState( - val developmentField: String = "", - val learningLevel: String = "", + val position: Position? = null, + val careerLevel: CareerLevel? = null, ) /** @@ -47,8 +51,8 @@ class SettingsViewModel( .onSuccess { profile -> _uiState.value = SettingsUiState( - developmentField = profile.position.toDisplayLabel(), - learningLevel = profile.careerLevel.toDisplayLabel(), + position = profile.position, + careerLevel = profile.careerLevel, ) }.onFailure { error -> logger.e(throwable = error) { "설정 프로필 조회 실패" } } } diff --git a/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt index e7cea45b..e747d2ac 100644 --- a/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt +++ b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt @@ -47,17 +47,21 @@ class MyViewModelTest { val state = viewModel.uiState.value assertEquals("김이박", state.profile.name) assertEquals("gitit@example.com", state.profile.email) - assertEquals("Back-end", state.profile.developmentField) - assertEquals("주니어", state.profile.learningLevel) - assertEquals(listOf("13문제", "47문제", "7일"), state.stats.map(MyStudyStat::value)) + assertEquals(Position.BACKEND, state.profile.position) + assertEquals(CareerLevel.JUNIOR, state.profile.careerLevel) + assertEquals( + listOf(MyStudyStatType.THIS_WEEK, MyStudyStatType.THIS_MONTH, MyStudyStatType.STREAK), + state.stats.map(MyStudyStat::type), + ) + assertEquals(listOf(13, 47, 7), state.stats.map(MyStudyStat::count)) assertEquals(listOf("월", "화"), state.weeklyStudy.map(MyWeeklyStudy::day)) assertEquals(listOf(3, 0), state.weeklyStudy.map(MyWeeklyStudy::solvedCount)) } } - /** 큐레이션 전이라 프로필 값이 비어 있으면 빈 라벨로 표시한다. */ + /** 큐레이션 전이라 프로필 값이 비어 있으면 선택 값 없이 표시한다. */ @Test - fun init_큐레이션전프로필이면_빈라벨로표시한다() { + fun init_큐레이션전프로필이면_선택값없이표시한다() { val emptyProfile = PROFILE.copy(name = null, email = null, position = null, careerLevel = null) @@ -67,8 +71,8 @@ class MyViewModelTest { val profile = viewModel.uiState.value.profile assertEquals("", profile.name) - assertEquals("", profile.developmentField) - assertEquals("", profile.learningLevel) + assertEquals(null, profile.position) + assertEquals(null, profile.careerLevel) } }