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/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/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/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/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/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/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/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/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/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/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/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/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/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 9548a17f..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,23 +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 meta 프로젝트·세트·문제 번호 정보 + * @property projectId 문제가 속한 프로젝트 식별자 + * @property projectName 문제가 속한 프로젝트 이름 + * @property setLabel 문제가 속한 학습 세트 라벨 + * @property problemNumber 세트 안에서의 문제 번호 * @property title 문제 제목 */ data class BookmarkedQuestion( val id: String, - val meta: String, + val projectId: 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 new file mode 100644 index 00000000..39099b66 --- /dev/null +++ b/feature/bookmark/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/bookmark/BookmarkMapper.kt @@ -0,0 +1,34 @@ +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 = null)) + + availableProjects.map { project -> BookmarkFilter(id = project.projectId, label = project.projectName) }, + selectedFilterId = selectedFilterId, + questions = + bookmarks.map { bookmark -> + BookmarkedQuestion( + id = bookmark.questionId, + projectId = bookmark.projectId, + 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/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/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/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..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,9 @@ 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 import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -12,9 +15,17 @@ import kotlinx.coroutines.launch /** * 저장한 문제 화면의 상태와 사용자 의도를 관리한다. + * + * @property getBookmarkedQuestions 북마크한 문제 목록을 조회하는 유스케이스 + * @property bookmarkQuestion 문제의 북마크 상태를 서버에 설정하는 유스케이스 */ -class BookmarkViewModel : ViewModel() { - private val _uiState = MutableStateFlow(dummyBookmarkUiState) +class BookmarkViewModel( + private val getBookmarkedQuestions: GetBookmarkedQuestionsUseCase, + private val bookmarkQuestion: BookmarkQuestionUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + private val _uiState = MutableStateFlow(BookmarkUiState(selectedFilterId = BOOKMARK_FILTER_ALL_ID)) /** * 저장한 문제 화면이 구독할 현재 UI 상태이다. @@ -28,6 +39,10 @@ class BookmarkViewModel : ViewModel() { */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadBookmarks(BOOKMARK_FILTER_ALL_ID) + } + /** * 저장한 문제 화면에서 발생한 사용자 의도를 처리한다. * @@ -39,18 +54,8 @@ 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.BookmarkClick -> { - setState { - copy( - bookmarkChanges = - bookmarkChanges + - (intent.questionId to !(bookmarkChanges[intent.questionId] ?: true)), - ) - } - } + is BookmarkIntent.FilterClick -> loadBookmarks(intent.filterId) + is BookmarkIntent.BookmarkClick -> toggleBookmark(intent.questionId) is BookmarkIntent.SolveClick -> { // TODO: 문제풀이 화면 route 추가 후 연결한다. } @@ -64,24 +69,37 @@ 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 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) { "문제 북마크 변경 실패" } } + } + } + + /** + * 선택한 필터로 북마크 목록을 조회해 화면 상태를 채운다. + * 실패하면 이전 목록을 유지하고 원인을 로그로 남긴다. + * + * @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..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 @@ -1,25 +1,145 @@ +@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.BookmarkQuestionUseCase +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 = createViewModel(repository) + runCurrent() + + val state = viewModel.uiState.value + assertEquals(null, repository.requestedProjectId) + assertEquals(listOf("all", "p1"), state.filters.map(BookmarkFilter::id)) + assertEquals(listOf(null, "flask"), state.filters.map(BookmarkFilter::label)) + assertEquals("all", state.selectedFilterId) + assertEquals(listOf("q1"), state.questions.map(BookmarkedQuestion::id)) + assertEquals("flask", state.questions.first().projectName) + assertEquals("Set 1", state.questions.first().setLabel) + assertEquals(3, state.questions.first().problemNumber) + assertEquals("블루프린트의 목적은?", state.questions.first().title) + } + } + + /** 프로젝트 필터를 선택하면 해당 프로젝트로 다시 조회한다. */ + @Test + fun filterClick_프로젝트필터를선택하면_프로젝트로다시조회한다() { + runTest(dispatcher) { + val repository = FakeBookmarkRepository(Result.success(BOOKMARKS)) + val viewModel = createViewModel(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 repository = FakeBookmarkRepository(Result.success(BOOKMARKS)) + val viewModel = createViewModel(repository) + 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)) + 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) + } + } - viewModel.onIntent(BookmarkIntent.BookmarkClick(questionId)) - assertEquals(mapOf(questionId to false), viewModel.uiState.value.bookmarkChanges) + private fun createViewModel(repository: FakeBookmarkRepository): BookmarkViewModel = + BookmarkViewModel( + getBookmarkedQuestions = GetBookmarkedQuestionsUseCase(repository), + bookmarkQuestion = BookmarkQuestionUseCase(repository), + ) - 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", + setId = "s1", + 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..b8e3acff --- /dev/null +++ b/feature/bookmark/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/bookmark/FakeBookmarkRepository.kt @@ -0,0 +1,86 @@ +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 { + /** 북마크 설정 요청이 실패해야 하면 지정하는 결과다. null이면 요청 값을 그대로 적용한다. */ + var bookmarkResult: Result? = null + + /** 마지막으로 북마크를 설정한 문제 식별자다. */ + var bookmarkedQuestionId: String? = null + + /** 마지막으로 북마크를 설정한 프로젝트 식별자다. */ + var bookmarkedProjectId: String? = null + + /** 마지막으로 요청한 북마크 상태다. */ + var requestedBookmarked: Boolean? = null + + /** 마지막으로 목록 조회에 사용한 프로젝트 필터다. */ + 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 { + bookmarkedQuestionId = questionId + bookmarkedProjectId = projectId + requestedBookmarked = bookmarked + return bookmarkResult ?: Result.success(bookmarked) + } + + override suspend fun getBookmarkedQuestions(projectId: String?): Result { + requestedProjectId = projectId + loadCount += 1 + return bookmarksResult + } +} diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 8961a243..1e9b7297 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -6,10 +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/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 52c7e533..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 /** * 홈에서 이어서 학습할 프로젝트 카드 정보다. @@ -25,13 +26,13 @@ data class HomeLearningProject( /** * 홈 화면이 표시할 상태를 정의한다. * - * @property userName 상단 프로필에 표시할 사용자 이름 - * @property userRole 사용자 이름 아래에 표시할 역할 + * @property userName 상단 프로필에 표시할 사용자 이름. 프로필 조회 전이면 빈 문자열 + * @property careerLevel 이름 아래 역할 표기를 만들 개발 수준. 조회 전이거나 큐레이션 전이면 null * @property learningProjects 이어서 학습할 프로젝트 목록. 비어 있으면 빈 상태를 표시한다 */ data class HomeUiState( - val userName: String = "김이박", - val userRole: String = "Junior Developer", + val userName: 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 new file mode 100644 index 00000000..2450b267 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/home/HomeProjectMapper.kt @@ -0,0 +1,40 @@ +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 + +/** + * 프로젝트 요약 도메인 모델을 홈 학습 카드 모델로 변환한다. + * + * @return 홈 화면에 표시할 학습 카드 + */ +internal fun ProjectSummary.toLearningProject(): HomeLearningProject = + HomeLearningProject( + id = projectId, + title = repositoryName, + technologies = techStack.joinToString(" · "), + setLabel = currentSetLabel, + description = currentSetTitle, + progress = overallProgressPercent / 100f, + ) + +/** + * 개발 수준을 홈 프로필 헤더의 역할 표기 문구 리소스로 변환한다. + * + * 시안의 표기(Junior Developer) 패턴을 수준별로 잇는다. + * + * @return 이름 아래에 표시할 역할 표기 리소스 + */ +internal fun CareerLevel.toRoleLabelResource(): StringResource = + when (this) { + 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/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/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 41ff75f2..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 @@ -1,18 +1,31 @@ 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 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 이어서 학습할 프로젝트 목록을 조회하는 유스케이스 + * @property getMemberProfile 프로필 헤더에 표시할 이름·역할을 조회하는 유스케이스 */ -class HomeViewModel : ViewModel() { - private val _uiState = MutableStateFlow(HomeUiState(learningProjects = dummyLearningProjects)) +class HomeViewModel( + private val getProjects: GetProjectsUseCase, + private val getMemberProfile: GetMemberProfileUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + private val _uiState = MutableStateFlow(HomeUiState()) /** * 홈 화면이 구독할 현재 UI 상태이다. @@ -26,6 +39,11 @@ class HomeViewModel : ViewModel() { */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadProjects() + loadProfile() + } + /** * 홈 화면에서 발생한 사용자 의도를 처리한다. * @@ -33,7 +51,10 @@ class HomeViewModel : ViewModel() { */ fun onIntent(intent: HomeIntent) { when (intent) { - HomeIntent.Refresh -> Unit + HomeIntent.Refresh -> { + loadProjects() + loadProfile() + } HomeIntent.HomeTabClick -> Unit HomeIntent.LoadProjectClick -> emit(HomeSideEffect.NavigateToProjectLoad) HomeIntent.ViewAllProjectsClick -> emit(HomeSideEffect.NavigateToProjectList) @@ -48,33 +69,34 @@ 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 loadProfile() { + viewModelScope.launch { + getMemberProfile() + .onSuccess { profile -> + _uiState.value = + _uiState.value.copy( + userName = profile.name.orEmpty(), + careerLevel = profile.careerLevel, + ) + }.onFailure { error -> logger.e(throwable = error) { "홈 프로필 조회 실패" } } + } + } + + /** + * 프로젝트 목록을 조회해 학습 카드를 채운다. + * 실패하면 이전 목록을 유지하고 원인을 로그로 남긴다. + */ + 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/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/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..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 @@ -1,16 +1,47 @@ +@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 +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 /** 홈 화면의 카드 배치와 입력 처리를 검증한다. */ +@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 +74,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 +86,49 @@ 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()) } + + /** 프로필 조회가 성공하면 헤더의 이름과 역할을 서버 값으로 채운다. */ + @Test + fun init_프로필조회에성공하면_헤더이름과역할을채운다() { + runTest(dispatcher) { + val viewModel = createViewModel() + runCurrent() + + assertEquals("김이박", viewModel.uiState.value.userName) + assertEquals(CareerLevel.JUNIOR, viewModel.uiState.value.careerLevel) + } + } + + 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/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/composeResources/drawable/settings_select_thumbnail.png b/feature/my/src/commonMain/composeResources/drawable/settings_select_thumbnail.png new file mode 100644 index 00000000..126b647a Binary files /dev/null and b/feature/my/src/commonMain/composeResources/drawable/settings_select_thumbnail.png differ diff --git a/feature/my/src/commonMain/composeResources/values/strings.xml b/feature/my/src/commonMain/composeResources/values/strings.xml index ac70b2a9..e10eac9f 100644 --- a/feature/my/src/commonMain/composeResources/values/strings.xml +++ b/feature/my/src/commonMain/composeResources/values/strings.xml @@ -3,12 +3,27 @@ 이번 주 첫 문제를 풀어볼까요? 이번 주도 차근차근 풀고 있어요 이번 주 학습을 하루도 놓치지 않았어요 + 이번 주 + 이번 달 + 연속 학습 + %1$d문제 + %1$d일 + Front-end + Back-end + iOS + Android + 입문 + 주니어 + 미들 + 시니어 설정 학습 설정 개발 분야 - Back-end 개발 수준 - 입문 + 프로젝트 코드를 처음 살펴봐요. + 작은 기능 단위로 코드를 이해할 수 있어요. + 프로젝트 구조와 흐름을 함께 살펴봐요. + 설계 의도와 변경 영향을 분석할 수 있어요. 알림 세트 생성 완료 알림 켜짐 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..f42f0ce0 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/MemberProfileMapper.kt @@ -0,0 +1,78 @@ +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 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 = + MyUiState( + profile = + MyProfile( + name = name.orEmpty(), + email = email.orEmpty(), + position = position, + careerLevel = careerLevel, + ), + stats = + listOf( + 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), + ) + +/** + * 요일별 문제 풀이량을 주간 차트 항목으로 변환한다. + * + * @return 요일 라벨과 풀이 수를 담은 차트 항목 + */ +private fun DaySolvedCount.toWeeklyStudy(): MyWeeklyStudy = + MyWeeklyStudy( + day = dayLabel, + solvedCount = count, + ) + +/** + * 개발 분야 표기에 쓸 문자열 리소스를 찾는다. + * + * @return 화면에 표시할 표기 문구 리소스 + */ +internal fun Position.toDisplayLabelResource(): StringResource = + when (this) { + 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 화면에 표시할 표기 문구 리소스 + */ +internal fun CareerLevel.toDisplayLabelResource(): StringResource = + when (this) { + 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/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/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/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/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..be78e1c3 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsCareerLevelRoute.kt @@ -0,0 +1,66 @@ +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_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 시안의 문구를 그대로 쓴다. + * + * @return 카드 제목 아래에 표시할 한 줄 설명 리소스 + */ +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 + } + +/** + * 개발 수준 선택 화면의 진입점(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 = stringResource(careerLevel.toDisplayLabelResource()), + description = stringResource(careerLevel.toDescriptionResource()), + ) + }, + 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/SettingsPositionRoute.kt b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsPositionRoute.kt new file mode 100644 index 00000000..3006d04c --- /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 = stringResource(position.toDisplayLabelResource())) + }, + 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..c89e103d --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsRoute.kt @@ -0,0 +1,45 @@ +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.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel + +/** + * 설정 화면의 진입점(Route)이다. + * + * 선택 화면에서 돌아올 때 최신 값을 표시하도록 화면이 나타날 때마다 프로필을 다시 조회한다. + * + * @param onBackClick 이전 화면으로 돌아가는 콜백 + * @param onPolicyClick 서비스 약관 및 정책 링크를 열도록 요청하는 콜백 + * @param onDeleteAccountClick 계정 삭제 안내 화면으로 이동하는 콜백 + * @param onDevelopmentFieldClick 개발 분야 선택 화면으로 이동하는 콜백 + * @param onLearningLevelClick 개발 수준 선택 화면으로 이동하는 콜백 + */ +@Composable +fun SettingsRoute( + onBackClick: () -> Unit, + onPolicyClick: () -> Unit, + onDeleteAccountClick: () -> Unit, + onDevelopmentFieldClick: () -> Unit, + onLearningLevelClick: () -> Unit, +) { + val viewModel = koinViewModel() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + viewModel.refresh() + } + + SettingsScreen( + developmentField = uiState.position?.let { stringResource(it.toDisplayLabelResource()) }.orEmpty(), + learningLevel = uiState.careerLevel?.let { stringResource(it.toDisplayLabelResource()) }.orEmpty(), + onBackClick = onBackClick, + 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 b1c0ea71..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 @@ -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,24 @@ import org.jetbrains.compose.resources.stringResource /** * 학습 환경과 계정 관련 메뉴를 표시하는 설정 화면이다. * + * @param developmentField 현재 선택한 개발 분야 표기 라벨. 큐레이션 전이면 빈 문자열 + * @param learningLevel 현재 선택한 개발 수준 표기 라벨. 큐레이션 전이면 빈 문자열 * @param onBackClick 이전 화면으로 돌아가는 콜백 * @param onPolicyClick 서비스 약관 및 정책 링크를 열도록 요청하는 콜백 * @param onDeleteAccountClick 계정 삭제 안내 화면으로 이동하는 콜백 + * @param onDevelopmentFieldClick 개발 분야 선택 화면으로 이동하는 콜백 + * @param onLearningLevelClick 개발 수준 선택 화면으로 이동하는 콜백 * @param modifier 화면의 크기와 배치를 지정할 수식자 */ @Composable fun SettingsScreen( + developmentField: String, + learningLevel: String, onBackClick: () -> Unit, onPolicyClick: () -> Unit, onDeleteAccountClick: () -> Unit, + onDevelopmentFieldClick: () -> Unit, + onLearningLevelClick: () -> Unit, modifier: Modifier = Modifier, ) { Column( @@ -94,13 +100,15 @@ 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, + onClick = onLearningLevelClick, ) } @@ -314,9 +322,13 @@ private fun SettingsIcon( private fun SettingsScreenPreview() { GitItTheme { SettingsScreen( + developmentField = "Back-end", + learningLevel = "입문", onBackClick = {}, onPolicyClick = {}, onDeleteAccountClick = {}, + onDevelopmentFieldClick = {}, + onLearningLevelClick = {}, ) } } 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..f220be73 --- /dev/null +++ b/feature/my/src/commonMain/kotlin/com/nexters/hytime/gitit/feature/my/SettingsViewModel.kt @@ -0,0 +1,60 @@ +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 +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +/** + * 설정 화면의 단일 UI 상태다. + * + * 표기 라벨은 화면이 문자열 리소스에서 만들므로 여기서는 선택 값만 들고 있다. + * + * @property position 현재 선택한 개발 분야. 큐레이션 전이면 null + * @property careerLevel 현재 선택한 개발 수준. 큐레이션 전이면 null + */ +data class SettingsUiState( + val position: Position? = null, + val careerLevel: CareerLevel? = null, +) + +/** + * 설정 화면에 표시할 회원의 현재 학습 설정을 관리한다. + * + * @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( + 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/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..e747d2ac --- /dev/null +++ b/feature/my/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/my/MyViewModelTest.kt @@ -0,0 +1,108 @@ +@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(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_큐레이션전프로필이면_선택값없이표시한다() { + 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(null, profile.position) + assertEquals(null, profile.careerLevel) + } + } + + /** 프로필 조회가 실패하면 초기 빈 상태를 유지한다. */ + @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/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/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/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..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 @@ -1,24 +1,35 @@ 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 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 프로젝트 상세 정보를 조회하는 유스케이스 + * @property deleteProject 프로젝트를 서버에서 삭제하는 유스케이스 */ class ProjectDetailViewModel( val projectId: String, + private val getProjectDetail: GetProjectDetailUseCase, + private val deleteProject: DeleteProjectUseCase, ) : ViewModel() { + private val logger = gitItLogger() + private val _uiState = MutableStateFlow(ProjectDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -26,7 +37,7 @@ class ProjectDetailViewModel( val events: SharedFlow = _events.asSharedFlow() init { - loadDummy() + loadProjectDetail() } /** @@ -65,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) { "프로젝트 삭제 실패" } } + } } /** @@ -91,26 +108,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..2c3691fb --- /dev/null +++ b/feature/projectdetail/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectdetail/FakeProjectDetailRepository.kt @@ -0,0 +1,67 @@ +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 상세 조회에 돌려줄 결과 + * @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, + ): 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 { + deletedProjectId = projectId + return deleteResult + } + + 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..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 @@ -1,60 +1,187 @@ +@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.DeleteProjectUseCase +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 repository = FakeProjectDetailRepository(Result.failure(IllegalStateException("오류"))) + val viewModel = + ProjectDetailViewModel( + projectId = "project-1", + getProjectDetail = GetProjectDetailUseCase(repository), + deleteProject = DeleteProjectUseCase(repository), + ) + 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_삭제에성공하면_홈이동이벤트를발행한다() { + runTest(dispatcher) { + 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()) } + } /** 프로젝트 상세의 플레이 버튼이 문제 풀이 이동 이벤트를 발행하는지 검증한다. */ @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 { + val repository = FakeProjectDetailRepository(Result.success(DETAIL)) + return ProjectDetailViewModel( + projectId = "project-1", + getProjectDetail = GetProjectDetailUseCase(repository), + deleteProject = DeleteProjectUseCase(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 = 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/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..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 @@ -1,6 +1,10 @@ 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 import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -8,12 +12,21 @@ 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 등록한 프로젝트 목록을 조회하는 유스케이스 + * @property deleteProject 프로젝트를 서버에서 삭제하는 유스케이스 */ -class ProjectListViewModel : ViewModel() { - private val _uiState = MutableStateFlow(ProjectListUiState(projects = dummyProjects)) +class ProjectListViewModel( + private val getProjects: GetProjectsUseCase, + private val deleteProject: DeleteProjectUseCase, +) : ViewModel() { + private val logger = gitItLogger() + + private val _uiState = MutableStateFlow(ProjectListUiState()) /** * 프로젝트 리스트 화면이 구독할 현재 UI 상태이다. @@ -27,6 +40,10 @@ class ProjectListViewModel : ViewModel() { */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadProjects() + } + /** * 프로젝트 리스트 화면에서 발생한 사용자 의도를 처리한다. * @@ -44,14 +61,7 @@ class ProjectListViewModel : ViewModel() { 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)) @@ -70,33 +80,39 @@ 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, - ), - ) + /** + * 서버에서 프로젝트를 삭제하고 성공하면 목록에서도 제거한다. + * 실패하면 목록을 유지하고 원인을 로그로 남긴다. + * + * @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) { "프로젝트 삭제 실패" } + } + } + } + + /** + * 프로젝트 목록을 조회해 화면 상태를 채운다. + * 실패하면 빈 목록을 유지하고 원인을 로그로 남긴다. + */ + 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..bd3cdd41 --- /dev/null +++ b/feature/projectlist/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/projectlist/FakeProjectListRepository.kt @@ -0,0 +1,67 @@ +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 목록 조회에 돌려줄 결과 + * @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, + ): 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 { + deletedProjectId = projectId + return deleteResult + } + + 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..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 @@ -1,20 +1,82 @@ +@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.DeleteProjectUseCase +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 repository = FakeProjectListRepository(Result.failure(IllegalStateException("오류"))) + val viewModel = + ProjectListViewModel( + getProjects = GetProjectsUseCase(repository), + deleteProject = DeleteProjectUseCase(repository), + ) + 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 +86,131 @@ 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()) } + } + + /** 삭제 요청이 실패하면 목록을 유지하고 모달만 닫는다. */ + @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 = + 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 = "라우팅 흐름 따라가기", + nextSetId = "s1", + nextQuestionId = "q1", + overallProgressPercent = 40, + ) + }, + hasNext = false, + ) + } } 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..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 @@ -1,23 +1,53 @@ 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 +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 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 문제 목록을 조회하는 유스케이스 + * @property submitChoiceAnswer 4지선다 답을 서버에 제출하고 채점 결과를 받는 유스케이스 + * @property submitEssayAnswer 서술형 답안을 서버에 제출하고 채점 기준을 받는 유스케이스 + * @property bookmarkQuestion 문제의 북마크 상태를 서버에 설정하는 유스케이스 + */ +class SolveQuizViewModel( + private val args: SolveQuizArgs, + private val getProjectDetail: GetProjectDetailUseCase, + private val getLearningSet: GetLearningSetUseCase, + private val submitChoiceAnswer: SubmitChoiceAnswerUseCase, + private val submitEssayAnswer: SubmitEssayAnswerUseCase, + private val bookmarkQuestion: BookmarkQuestionUseCase, ) : ViewModel() { + private val logger = gitItLogger() + private val _uiState = MutableStateFlow(SolveQuizUiState()) /** 문제 풀이 화면이 구독할 현재 상태다. */ @@ -28,6 +58,10 @@ class SolveQuizViewModel( /** 화면 이동과 외부 URL 열기를 전달하는 이벤트 스트림이다. */ val sideEffects: SharedFlow = _sideEffects.asSharedFlow() + init { + loadLearningSet() + } + /** * 문제 풀이 화면에서 발생한 사용자 의도를 처리한다. * @@ -35,20 +69,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 } @@ -76,31 +148,72 @@ 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 } } + /** + * 선택한 답을 서버에 제출하고 채점 결과로 정답·해설을 채운다. + * 실패하면 제출 전 상태를 유지해 다시 시도할 수 있게 하고 원인을 로그로 남긴다. + */ private fun submitMultipleChoiceAnswer(state: SolveQuizUiState) { val selectedAnswerId = state.selectedAnswerId ?: return if (state.isMultipleChoiceSubmitted) return + val selectedIndex = selectedAnswerId.toIntOrNull() ?: return - setState { - copy( - isMultipleChoiceSubmitted = true, - expandedAnswerIds = setOf(selectedAnswerId, multipleChoiceQuestion.correctAnswerId), - ) + 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지선다 답변 제출 실패" } } } } - private fun moveToNextStep() { + /** + * 작성한 답안을 서버에 제출하고 채점 기준의 만점 예시를 모범 답안으로 채운다. + * 실패하면 제출 전 상태를 유지해 다시 시도할 수 있게 하고 원인을 로그로 남긴다. + */ + 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 + 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() } } } @@ -110,17 +223,38 @@ 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) { "문제 북마크 변경 실패" } } + } + } + + /** 출처 URL이 있을 때만 외부 열기 이벤트를 발행한다. */ + private fun openSource() { + val url = uiState.value.currentSource().url + if (url.isNotBlank()) { + _sideEffects.tryEmit(SolveQuizSideEffect.OpenUrl(url)) } } @@ -129,6 +263,76 @@ class SolveQuizViewModel( } } +/** + * 이어서 풀 세트 요약을 고른다. + * + * @param setId 화면 진입 시 지정한 세트 식별자. 없으면 진행 상황으로 고른다 + * @return 지정한 세트, 없으면 다 풀지 않은 첫 세트, 그것도 없으면 첫 세트 + */ +private fun List.targetSummary(setId: String?): LearningSetSummary? = + if (setId != null) { + firstOrNull { it.setId == setId } + } else { + 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 + } + } + +/** + * 현재 서술형 문제에 모범 답안을 채운 목록을 만든다. + * + * @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) { + 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..604f8b84 --- /dev/null +++ b/feature/quiz/src/commonTest/kotlin/com/nexters/hytime/gitit/feature/quiz/solve/FakeSolveQuizRepository.kt @@ -0,0 +1,110 @@ +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 학습 세트 조회에 돌려줄 결과 + * @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 { + /** 북마크 설정 요청이 실패해야 하면 지정하는 결과다. null이면 요청 값을 그대로 적용한다. */ + var bookmarkResult: Result? = null + + /** 마지막으로 북마크를 설정한 문제 식별자다. */ + var bookmarkedQuestionId: String? = null + + /** 마지막으로 북마크를 설정한 프로젝트 식별자다. */ + var bookmarkedProjectId: String? = null + + /** 마지막으로 요청한 북마크 상태다. */ + var requestedBookmarked: Boolean? = null + + /** 마지막으로 서술형 답을 제출한 문제 식별자다. */ + var submittedEssayQuestionId: String? = null + + /** 마지막으로 제출한 서술형 답안이다. */ + var submittedEssayText: String? = null + + /** 마지막으로 4지선다 답을 제출한 문제 식별자다. */ + var submittedChoiceQuestionId: String? = null + + /** 마지막으로 제출한 선택지 번호다. */ + var submittedChoiceIndex: Int? = null + + /** 마지막으로 학습 세트를 조회한 세트 식별자다. */ + 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 { + submittedChoiceQuestionId = questionId + submittedChoiceIndex = selectedIndex + return choiceAnswerResult + } + + override suspend fun submitEssayAnswer( + projectId: String, + questionId: String, + text: String, + ): Result { + submittedEssayQuestionId = questionId + submittedEssayText = text + return essayAnswerResult + } + + override suspend fun bookmarkQuestion( + projectId: String, + questionId: String, + bookmarked: Boolean, + ): 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 805c85fb..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 @@ -1,141 +1,461 @@ +@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.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 +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.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 +import com.nexters.hytime.gitit.domain.usecase.SubmitEssayAnswerUseCase +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() - viewModel.onIntent(SolveQuizIntent.Start) - viewModel.onIntent(SolveQuizIntent.AnswerClick("set-content")) - viewModel.onIntent(SolveQuizIntent.Submit) + 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) + } + } + + /** 세트 식별자를 지정하지 않으면 다 풀지 않은 첫 세트를 고른다. */ + @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) + runCurrent() - 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(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) + runCurrent() + + 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 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.Submit) - assertTrue(viewModel.uiState.value.isEssaySubmitted) + viewModel.onIntent(SolveQuizIntent.BookmarkClick) + runCurrent() - viewModel.onIntent(SolveQuizIntent.Next) - assertEquals(QuizStep.Completed, viewModel.uiState.value.step) + assertEquals("q2", repository.bookmarkedQuestionId) + 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 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) + viewModel.onIntent(SolveQuizIntent.BookmarkClick) + runCurrent() - assertEquals(setOf(1, 2), viewModel.uiState.value.bookmarkedQuestionNumbers) + assertEquals(emptySet(), viewModel.uiState.value.bookmarkedQuestionNumbers) + } } - /** 학습 완료 화면을 닫으면 다음 진입을 위해 초기 상태로 돌아간다. */ + /** 뒤로가기는 진행 상태를 소개 단계로 되돌린다. */ @Test - fun onIntent_completedBackClick_resetsState() { - val viewModel = essayViewModel() - viewModel.onIntent(SolveQuizIntent.Submit) - viewModel.onIntent(SolveQuizIntent.Next) + fun onIntent_backClick_resetsToIntro() { + runTest(dispatcher) { + val viewModel = essayViewModel() - viewModel.onIntent(SolveQuizIntent.BackClick) + viewModel.onIntent(SolveQuizIntent.BackClick) - assertEquals(SolveQuizUiState(), viewModel.uiState.value) + val state = viewModel.uiState.value + assertEquals(QuizStep.Intro, state.step) + assertEquals(0, state.currentIndex) + assertEquals("", state.essayAnswer) + assertEquals(3, state.questions.size) + } } - /** 서술형 단계까지 이동한 ViewModel을 만든다. */ - private fun essayViewModel(): SolveQuizViewModel = - createViewModel().apply { - onIntent(SolveQuizIntent.Start) - onIntent(SolveQuizIntent.AnswerClick("set-content")) - onIntent(SolveQuizIntent.Submit) - onIntent(SolveQuizIntent.Next) + /** 제출이 성공하면 서버 채점 결과로 정답과 해설을 채우고 두 답안을 펼친다. */ + @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 submit_서술형제출에성공하면_모범답안을채운다() { + runTest(dispatcher) { + val repository = repositoryWithEssayResult() + val viewModel = essayViewModel(repository) - /** 테스트에서 사용할 프로젝트 식별자가 포함된 ViewModel을 만든다. */ - private fun createViewModel(): SolveQuizViewModel = SolveQuizViewModel(projectId = "project-1") + 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() { + 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(GitItMultipleChoiceAnswerState.Incorrect, state.answerCardState("1")) + assertEquals(GitItMultipleChoiceAnswerState.Correct, state.answerCardState("0")) + assertEquals(GitItMultipleChoiceAnswerState.Folded, state.answerCardState("2")) + } + + /** 서술형 단계까지 이동한 ViewModel을 만든다. */ + private fun TestScope.essayViewModel(repository: FakeSolveQuizRepository = repositoryWithEssayResult()): SolveQuizViewModel { + val viewModel = createViewModel(repository = repository) + runCurrent() + viewModel.onIntent(SolveQuizIntent.Start) + viewModel.onIntent(SolveQuizIntent.AnswerClick("0")) + viewModel.onIntent(SolveQuizIntent.Submit) + runCurrent() + viewModel.onIntent(SolveQuizIntent.Next) + 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( + 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), + submitEssayAnswer = SubmitEssayAnswerUseCase(repository), + bookmarkQuestion = BookmarkQuestionUseCase(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 1b92c1e4..680ab8ff 100644 --- a/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt +++ b/shared/src/commonMain/kotlin/com/nexters/hytime/gitit/Di.kt @@ -4,15 +4,36 @@ 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.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 +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 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 +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 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 @@ -36,10 +57,40 @@ val appModule: Module = ) } single { LoadGitHubRepositoryUseCase(repository = get()) } + single { GetMemberProfileUseCase(repository = get()) } + single { GetProjectsUseCase(repository = get()) } + single { GetProjectDetailUseCase(repository = get()) } + single { DeleteProjectUseCase(repository = get()) } + single { GetLearningSetUseCase(repository = get()) } + single { SubmitChoiceAnswerUseCase(repository = get()) } + single { SubmitEssayAnswerUseCase(repository = get()) } + 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 { params -> ProjectDetailViewModel(projectId = params.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(), getMemberProfile = get()) } + viewModel { BookmarkViewModel(getBookmarkedQuestions = get(), bookmarkQuestion = get()) } + viewModel { ProjectListViewModel(getProjects = get(), deleteProject = get()) } + viewModel { params -> + ProjectDetailViewModel(projectId = params.get(), getProjectDetail = get(), deleteProject = get()) + } viewModel { ProjectLoadViewModel(loadGitHubRepository = get()) } + viewModel { params -> + SolveQuizViewModel( + args = params.get(), + getProjectDetail = get(), + getLearningSet = get(), + submitChoiceAnswer = get(), + submitEssayAnswer = get(), + bookmarkQuestion = get(), + ) + } single { QuizCreateStore() } single { QuizCreateRetryHandler(registerProject = get(), createStore = get()) } viewModel { params -> 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..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,7 +31,9 @@ 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.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 import com.nexters.hytime.gitit.feature.projectdetail.ProjectDetailRoute import com.nexters.hytime.gitit.feature.projectlist.ProjectListRoute @@ -73,6 +75,8 @@ internal val appRouteSavedStateConfiguration = subclass(AppRoute.Home.serializer()) 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()) @@ -116,6 +120,8 @@ internal fun AppRoute.navigationMotion(): AppNavigationMotion = AppRoute.LiquidGlassExample, is AppRoute.ProjectDetail, AppRoute.Settings, + AppRoute.SettingsCareerLevel, + AppRoute.SettingsPosition, -> AppNavigationMotion.Horizontal AppRoute.ProjectLoad, @@ -273,10 +279,22 @@ 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) }, + onLearningLevelClick = { backStack.add(AppRoute.SettingsCareerLevel) }, + ) + } + entry(metadata = AppRoute.SettingsPosition.navigationMetadata()) { + SettingsPositionRoute( + onNavigateBack = { backStack.removeLastOrNull() }, + ) + } + entry(metadata = AppRoute.SettingsCareerLevel.navigationMetadata()) { + SettingsCareerLevelRoute( + 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..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 @@ -46,6 +46,14 @@ sealed interface AppRoute : NavKey { @Serializable data object Settings : AppRoute + /** 설정에서 개발 분야를 변경하는 선택 화면이다. */ + @Serializable + data object SettingsPosition : AppRoute + + /** 설정에서 개발 수준을 변경하는 선택 화면이다. */ + @Serializable + data object SettingsCareerLevel : AppRoute + /** 계정 삭제 전 주의사항을 안내하는 화면이다. */ @Serializable data object AccountDelete : AppRoute