Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "e8b0c98f11a3e10bbae77fa64b2c1a67",
"identityHash": "cb35ad76919b5cac5c41199d7b88c43a",
"entities": [
{
"tableName": "quiz",
Expand Down Expand Up @@ -251,7 +251,17 @@
"localId"
]
},
"indices": [],
"indices": [
{
"name": "index_QuizBookGradeEntity_quizBookId",
"unique": true,
"columnNames": [
"quizBookId"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_QuizBookGradeEntity_quizBookId` ON `${TABLE_NAME}` (`quizBookId`)"
}
],
"foreignKeys": []
},
{
Expand Down Expand Up @@ -324,7 +334,7 @@
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e8b0c98f11a3e10bbae77fa64b2c1a67')"
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cb35ad76919b5cac5c41199d7b88c43a')"
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.android.quizcafe.feature.quiz.solve

import com.android.quizcafe.feature.quiz.solve.viewmodel.IQuizSolveViewModel
import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveEffect
import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveIntent
import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveUiState
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

class FakeQuizSolveViewModel : IQuizSolveViewModel {
private val _state = MutableStateFlow(QuizSolveUiState())
override val state: StateFlow<QuizSolveUiState> = _state.asStateFlow()

private val _effect = MutableSharedFlow<QuizSolveEffect>()
override val effect: SharedFlow<QuizSolveEffect> = _effect.asSharedFlow()

val receivedIntents = mutableListOf<QuizSolveIntent>()

override fun sendIntent(intent: QuizSolveIntent) {
receivedIntents.add(intent)
}

// 테스트 편의를 위한 함수들 (인터페이스에는 포함되지 않음)
suspend fun emitEffect(effect: QuizSolveEffect) {
_effect.emit(effect)
}

fun setState(newState: QuizSolveUiState) {
_state.value = newState
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.android.quizcafe.feature.quiz.solve

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.test.espresso.Espresso
import com.android.quizcafe.core.ui.util.TestTags
import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveEffect
import com.android.quizcafe.feature.quiz.solve.viewmodel.QuizSolveIntent
import junit.framework.TestCase.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class QuizSolveScreenTest {

// 컴포즈 테스트 환경 설정 규칙
@get:Rule
val composeTestRule = createComposeRule()
private lateinit var fakeViewModel: FakeQuizSolveViewModel

@Before
fun setUp() {
fakeViewModel = FakeQuizSolveViewModel()
}

// 실제 앱에서는 ViewModel에서 이 로직을 처리하지만 테스트에서는 onIntent 람다에서 직접 상태를 변경하여 ViewModel의 동작 처리
@Test
fun whenBackPressed_showsExitDialog_viaViewModelEffect() = runTest {
var wasNavigateBackCalled = false

composeTestRule.setContent {
QuizSolveRoute(
quizBookId = 1L,
navigateToBack = { wasNavigateBackCalled = true },
navigateToQuizBookSolvingResult = {},
viewModel = fakeViewModel
)
}

composeTestRule.onNodeWithTag(TestTags.QuizSolve.EXIT_DIALOG).assertDoesNotExist()

Espresso.pressBack()
// Thread.sleep(2000) // 디버깅용
assertTrue(fakeViewModel.receivedIntents.any { it is QuizSolveIntent.OnBackClick })

// ViewModel 동작 시뮬레이션 및 UI 검증
fakeViewModel.emitEffect(QuizSolveEffect.ShowExitDialog)
composeTestRule.onNodeWithTag(TestTags.QuizSolve.EXIT_DIALOG).assertIsDisplayed()

// 다이얼로그 내 버튼 클릭 및 검증
composeTestRule.onNodeWithTag(TestTags.QuizSolve.EXIT_DIALOG_EXIT_SAVE_BUTTON).performClick()
assertTrue(fakeViewModel.receivedIntents.any { it is QuizSolveIntent.ExitWithSave })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ class QuizBookSolvingRepositoryTest {
setupTestData(quizBookId)

val results = mutableListOf<Resource<*>>()
repository.createEmptyQuizBookGrade(quizBookId).collect(results::add)
repository.getOrCreateQuizBookGrade(quizBookId).collect(results::add)

assertEquals(2, results.size)
assertTrue(results[0] is Resource.Loading)
Expand All @@ -98,8 +98,8 @@ class QuizBookSolvingRepositoryTest {
setupTestData(quizBookId)

// 먼저 퀴즈북 풀이 생성
val quizBookGradeLocalId = repository.createEmptyQuizBookGrade(quizBookId).first { it is Resource.Success }
.let { (it as Resource.Success).data }
val quizBookGradeLocalId = repository.getOrCreateQuizBookGrade(quizBookId).first { it is Resource.Success }
.let { (it as Resource.Success).data }.localId

val quizGrade = QuizGrade(
localId = 1L,
Expand Down Expand Up @@ -140,7 +140,7 @@ class QuizBookSolvingRepositoryTest {
assertEquals(quizGrade.memo, savedQuizGrade.memo)

// Repository를 통해 조회한 데이터와도 비교
val repositoryResult = repository.getQuizBookGrade(quizBookGradeLocalId)
val repositoryResult = repository.getOrCreateQuizBookGrade(quizBookId)
.first { it is Resource.Success }
.let { (it as Resource.Success).data }

Expand All @@ -162,9 +162,9 @@ class QuizBookSolvingRepositoryTest {
val quizBookId = QuizBookId(1L)
setupTestData(quizBookId)

val quizBookGradeLocalId = repository.createEmptyQuizBookGrade(quizBookId)
val quizBookGradeLocalId = repository.getOrCreateQuizBookGrade(quizBookId)
.first { it is Resource.Success }
.let { (it as Resource.Success).data }
.let { (it as Resource.Success).data }.localId

// 3개 문제 중 1번만 틀리고 나머지는 맞음
val quizGrades = listOf(
Expand Down Expand Up @@ -204,7 +204,7 @@ class QuizBookSolvingRepositoryTest {

// When
val solveResults = mutableListOf<Resource<QuizBookGradeServerId>>()
repository.solveQuizBook(quizBookGradeLocalId).collect(solveResults::add)
repository.solveQuizBook(quizBookGradeLocalId, 1L).collect(solveResults::add)

// Then
assertEquals(2, solveResults.size)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import com.android.quizcafe.core.data.model.quizbook.response.QuizBookWithQuizze
import com.android.quizcafe.core.database.model.quizbook.QuizBookEntity
import com.android.quizcafe.core.database.model.quizbook.QuizBookWithQuizRelation
import com.android.quizcafe.core.domain.model.quizbook.response.QuizBook
import com.android.quizcafe.core.domain.model.value.QuizBookId

fun QuizBookEntity.toDomain() = QuizBook(
id = id,
id = QuizBookId(id),
version = version,
category = category,
title = title,
Expand All @@ -23,7 +24,7 @@ fun QuizBookEntity.toDomain() = QuizBook(
)

fun QuizBookResponseDto.toDomain() = QuizBook(
id = quizBookId,
id = QuizBookId(quizBookId),
version = version,
category = category,
description = description,
Expand All @@ -49,7 +50,7 @@ fun QuizBookWithQuizzesResponseDto.toEntity() = QuizBookEntity(
)

fun QuizBookWithQuizzesResponseDto.toDomain() = QuizBook(
id = quizBookId,
id = QuizBookId(quizBookId),
version = version,
category = category,
description = description,
Expand All @@ -64,7 +65,7 @@ fun QuizBookWithQuizzesResponseDto.toDomain() = QuizBook(
)

fun QuizBookWithQuizRelation.toDomain() = QuizBook(
id = quizBookEntity.id,
id = QuizBookId(quizBookEntity.id),
version = quizBookEntity.version,
category = quizBookEntity.category,
title = quizBookEntity.title,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ import com.android.quizcafe.core.database.dao.quiz.QuizDao
import com.android.quizcafe.core.database.dao.quiz.QuizGradeDao
import com.android.quizcafe.core.database.dao.quizBook.QuizBookDao
import com.android.quizcafe.core.database.dao.quizBook.QuizBookGradeDao
import com.android.quizcafe.core.database.model.quizbook.QuizBookEntity
import com.android.quizcafe.core.database.model.grading.QuizBookGradeEntity
import com.android.quizcafe.core.database.model.grading.QuizGradeEntity
import com.android.quizcafe.core.database.model.quizbook.QuizBookEntity
import com.android.quizcafe.core.domain.model.Resource
import com.android.quizcafe.core.domain.model.quiz.QuizGrade
import com.android.quizcafe.core.domain.model.solving.QuizBookGrade
import com.android.quizcafe.core.domain.model.solving.QuizBookSolving
import com.android.quizcafe.core.domain.model.solving.QuizBookGrade
import com.android.quizcafe.core.domain.model.value.QuizBookGradeLocalId
import com.android.quizcafe.core.domain.model.value.QuizBookGradeServerId
import com.android.quizcafe.core.domain.model.value.QuizBookId
Expand All @@ -36,7 +36,6 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
import kotlin.collections.map

class QuizBookSolvingRepositoryImpl @Inject constructor(
private val quizGradeDao: QuizGradeDao,
private val quizDao: QuizDao,
Expand All @@ -49,15 +48,24 @@ class QuizBookSolvingRepositoryImpl @Inject constructor(
* 퀴즈북 풀기 시작할 때 호출
* localId값 반환
*/
override fun createEmptyQuizBookGrade(id: QuizBookId): Flow<Resource<QuizBookGradeLocalId>> = flow {
override fun getOrCreateQuizBookGrade(quizBookId: QuizBookId): Flow<Resource<QuizBookGrade>> = flow {
emit(Resource.Loading)
val entity = QuizBookGradeEntity(quizBookId = id.value)
val generatedId = quizBookGradeDao.upsertQuizBookGrade(entity)
val quizBookGradeRelation = quizBookGradeDao.getQuizBookGrade(quizBookId.value)

if (generatedId <= 0L) {
emit(Resource.Failure(errorMessage = "QuizBookGrade 생성 실패", code = LocalErrorCode.ROOM_ERROR))
if (quizBookGradeRelation != null) {
emit(Resource.Success(quizBookGradeRelation.toDomain()))
} else {
emit(Resource.Success(QuizBookGradeLocalId(generatedId)))
val entity = QuizBookGradeEntity(quizBookId = quizBookId.value)
val quizBookGradeLocalId = quizBookGradeDao.upsertQuizBookGrade(entity)

if (quizBookGradeLocalId <= 0L) {
emit(Resource.Failure(errorMessage = "QuizBookGrade 생성 실패", code = LocalErrorCode.ROOM_ERROR))
} else {
val relation = quizBookGradeDao.getQuizBookGradeByLocalId(quizBookGradeLocalId)
relation?.let {
emit(Resource.Success(it.toDomain()))
} ?: emit(Resource.Failure(errorMessage = "QuizBookGrade 생성 후 조회 실패", code = LocalErrorCode.ROOM_ERROR))
}
}
}.catch { e ->
emit(Resource.Failure(errorMessage = "QuizBookGrade 생성 중 오류: ${e.message}", code = LocalErrorCode.ROOM_ERROR))
Expand All @@ -68,15 +76,14 @@ class QuizBookSolvingRepositoryImpl @Inject constructor(
val quizGradeRelation = quizGradeDao.getQuizGradeByQuizId(quizId.value, quizBookGradeLocalId.value)
val quizGrade = quizGradeRelation?.toDomain()
if (quizGrade == null) {
emit(Resource.Failure(errorMessage = "퀴즈 Grade 조회 실패", code = LocalErrorCode.ROOM_ERROR))
emit(Resource.Failure(errorMessage = "getQuizGrade 중 quizGrade is null", code = LocalErrorCode.ROOM_ERROR))
} else {
emit(Resource.Success(quizGrade))
}
}.catch { e ->
emit(Resource.Failure(errorMessage = "퀴즈 한문제씩 가져오다가 오류: ${e.message}", code = LocalErrorCode.ROOM_ERROR))
}

// 퀴즈북 풀이 기록 가져오기
override fun getQuizBookGrade(id: QuizBookGradeLocalId): Flow<Resource<QuizBookGrade>> = flow {
emit(Resource.Loading)
val quizBookGradeRelation = quizBookGradeDao.getQuizBookGrade(id.value)
Expand Down Expand Up @@ -125,13 +132,25 @@ class QuizBookSolvingRepositoryImpl @Inject constructor(
emit(Resource.Failure(errorMessage = "퀴즈 풀이 기록 저장 중 오류: ${e.message}", code = LocalErrorCode.ROOM_ERROR))
}

override fun deleteQuizBookGrade(id: QuizBookGradeLocalId): Flow<Resource<Unit>> = flow {
emit(Resource.Loading)
val deletedCnt = quizBookGradeDao.deleteQuizBookGrade(id.value)
if (deletedCnt == 1) {
emit(Resource.Success(Unit))
} else {
emit(Resource.Failure(errorMessage = "퀴즈북 풀이 기록 삭제 중 오류 : deletedCnt = $deletedCnt", code = LocalErrorCode.ROOM_ERROR))
}
}.catch { e ->
emit(Resource.Failure(errorMessage = "퀴즈북 풀이 기록 삭제 중 오류: ${e.message}", code = LocalErrorCode.ROOM_ERROR))
}

// 로컬에서 퀴즈북 풀이 기록 가져와 requestDto로 변환 후 퀴즈북 풀이 완료 API 요청하기
override fun solveQuizBook(
quizBookGradeLocalId: QuizBookGradeLocalId,
localId: QuizBookGradeLocalId,
elapsedTimeInSeconds: Long
): Flow<Resource<QuizBookGradeServerId>> = flow {
emit(Resource.Loading)
val (quizBookGradeEntity, quizGradeEntities) = getQuizBookGradeData(quizBookGradeLocalId)
val (quizBookGradeEntity, quizGradeEntities) = getQuizBookGradeRelation(localId)
val quizBookEntity = getQuizBookEntity(quizBookGradeEntity.quizBookId)

val requestDto = createQuizBookSolvingRequest(
Expand All @@ -144,7 +163,7 @@ class QuizBookSolvingRepositoryImpl @Inject constructor(
quizBookSolvingRemoteDataSource.solveQuizBook(requestDto)
.onSuccess { response ->
response.data?.let { serverId ->
quizBookGradeDao.deleteQuizBookGrade(quizBookGradeLocalId.value)
quizBookGradeDao.deleteQuizBookGrade(localId.value)
deleteQuizBookFromLocal(
QuizBookId(quizBookGradeEntity.quizBookId)
)
Expand All @@ -159,10 +178,10 @@ class QuizBookSolvingRepositoryImpl @Inject constructor(
}

// 로컬에서 퀴즈북 풀이 기록 가져오기 - null 체크 추가
private suspend fun getQuizBookGradeData(
private suspend fun getQuizBookGradeRelation(
localId: QuizBookGradeLocalId
): Pair<QuizBookGradeEntity, List<QuizGradeEntity>> {
val quizBookGradeRelation = quizBookGradeDao.getQuizBookGrade(localId.value)
val quizBookGradeRelation = quizBookGradeDao.getQuizBookGradeByLocalId(localId.value)
?: throw IllegalStateException("QuizBookGrade not found for localId: ${localId.value}")

return Pair(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ interface QuizBookGradeDao {
@Upsert
suspend fun upsertQuizBookGrade(entity: QuizBookGradeEntity): Long

// QuizBookGradeEntity LocalId로 QuizQuizBookSolvingResult 리스트 반환
@Transaction
@Query("SELECT * FROM QuizBookGradeEntity WHERE localId = :localId")
suspend fun getQuizBookGrade(localId: Long): QuizBookGradeWithQuizGradesRelation?
@Query("SELECT * FROM QuizBookGradeEntity WHERE localId = :quizBookGradeLocalId")
suspend fun getQuizBookGradeByLocalId(quizBookGradeLocalId: Long): QuizBookGradeWithQuizGradesRelation?

@Transaction
@Query("SELECT * FROM QuizBookGradeEntity WHERE quizBookId = :quizBookId")
suspend fun getQuizBookGrade(quizBookId: Long): QuizBookGradeWithQuizGradesRelation?

@Query("DELETE FROM QuizBookGradeEntity WHERE localId = :localId")
suspend fun deleteQuizBookGrade(localId: Long)
suspend fun deleteQuizBookGrade(localId: Long): Int
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,18 @@
package com.android.quizcafe.core.database.model.grading

import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
import kotlin.time.Duration

@Entity
@Entity(
indices = [
Index(
value = ["quizBookId"],
unique = true
)
]
)
data class QuizBookGradeEntity(
@PrimaryKey(autoGenerate = true)
val localId: Long = 0,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package com.android.quizcafe.core.domain.model.quizbook.response

import com.android.quizcafe.core.domain.model.quiz.Quiz
import com.android.quizcafe.core.domain.model.value.QuizBookId

data class QuizBook(
val id: Long,
val id: QuizBookId,
val version: Long,
val category: String,
val title: String,
Expand Down
Loading
Loading