Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CT-2-2-3] 차트게임 기능 - 실행 테스트 및 버그 수정 #35

Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
85cada9
[CT-2-2-3] design: 상태바 아이콘 컬러 반전
nosorae Jun 3, 2024
3e0e04c
[CT-2-2-3] design: 주문 UI 얼라인 바텀 처리
nosorae Jun 3, 2024
0573512
[CT-2-2-3] design: 매매 주문 Ui ShortCurChip 패딩/간격 수정
nosorae Jun 3, 2024
012f3cd
[CT-2-2-3] fix: NumberFormatException 으로 발생하는 크래시 수정
nosorae Jun 3, 2024
c9f5535
[CT-2-2-3] design: TradeOrderUi 키패드 애니메이션 제거
nosorae Jun 3, 2024
c0c9c88
[CT-2-2-3] design: TradeOrderUi 모달바텀시트로 변경 (뒷배경, 클릭 방지, 애니메이션 등의 이유)
nosorae Jun 3, 2024
a922675
[CT-2-2-3] style: 불필요 필드 제거
nosorae Jun 3, 2024
568e9ac
[CT-2-2-3] fix: TradeOrderUi KeyPad 버그 수정, userInput non-null로 변경
nosorae Jun 3, 2024
d3cadd8
[CT-2-2-3] design: TradeOrderUi 금액 소수점 두 자리수로 자르기 (함수 하나로 통일하게 리팩토링 예정)
nosorae Jun 3, 2024
d2f060d
[CT-2-2-3] design: TradeOrderUi 하단 버튼 shape 수정
nosorae Jun 3, 2024
17809f7
[CT-2-2-3] design: 로딩 프로그래스 바 추가
nosorae Jun 4, 2024
068198d
[CT-2-2-3] fix: currentGameProgress 를 % 정수가 아니라 소수로 표현되게 수정
nosorae Jun 4, 2024
d3ccf97
[CT-2-2-3] fix: TopAppBar 초기 상태는 타이틀로 보여주기
nosorae Jun 4, 2024
afd02be
[CT-2-2-3] design: ChartGameBottomBarUi 에 디바이더 추가, 간격조정
nosorae Jun 4, 2024
c949dc7
[CT-2-2-3] fix: ChartGameBottomBarUi 매도매수 버튼 활성화 조건 수정
nosorae Jun 4, 2024
c123aab
[CT-2-2-3] fix: 다음 턴(틱) 보여주는 기능 정상화
nosorae Jun 4, 2024
af50e7c
[CT-2-2-3] fix: NaN 처리 추가
nosorae Jun 4, 2024
c6d1c4d
[CT-2-2-3] fix: Flow<Result<Unit>> 일 때 Success 값 오지 않는 이슈 수정
nosorae Jun 4, 2024
8568076
[CT-2-2-3] fix: 매매기능 정상화, 매도 주문 커스텀 소프트 인풋에 대해 toInt 누락 추가 처리
nosorae Jun 6, 2024
95391ad
[CT-2-2-3] fix: 불러온 차트가 틱이 50개 미만이면 게임을 정상적으로 진행할 수 없으니 재시도와 가이드 추가
nosorae Jun 6, 2024
2c69b2a
[CT-2-2-3] style: ktlintFormat
nosorae Jun 6, 2024
5fa51fd
[CT-2-2-3] fix: 게임 종료 정상화
nosorae Jun 6, 2024
4310b54
[CT-2-2-3] fix: 버튼 활성화 여부 조건에 게임종료인 경우 고려
nosorae Jun 6, 2024
c47db0b
Merge branch 'feature/ct-2-2-2-chart-game-presentation-screen' into f…
nosorae Jun 6, 2024
9019b5a
[CT-2-2-3] refactor: Screen 으로부터 이벤트 분기를 분리
nosorae Jun 6, 2024
7ee037b
[2-2-3] fix: 전량 매도시에 totalProfit, rateOfProfit 이 0이 되는 버그 수정 (totalPr…
nosorae Jun 9, 2024
59d2c0f
[CT-2-2-3] fix: 음수일 때도 버튼 활성화되게 수정
nosorae Jun 9, 2024
c1231e7
[CT-2-2-3] 리뷰반영 - dto의 ticks를 널 대신 빈 리스트로 초기화
nosorae Jun 10, 2024
8a07b8e
[CT-2-2-3] 리뷰반영 - 차트의 tick 리스트가 부족할 때 그대로 보여준다.
nosorae Jun 10, 2024
3b02a4f
[CT-2-2-3] 리뷰반영 - 불필요한 소괄호 제거
nosorae Jun 10, 2024
d610fd1
[CT-2-2-3] 리뷰반영 - 코드의 간결함을 위해 반복되는 코드는 확장함수등으로 만들기
nosorae Jun 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import com.yessorae.data.source.network.polygon.model.chart.asDomainModel
import com.yessorae.domain.common.ChartRequestArgumentHelper
import com.yessorae.domain.entity.Chart
import com.yessorae.domain.entity.tick.TickUnit
import com.yessorae.domain.exception.ChartGameException
import com.yessorae.domain.repository.ChartRepository
import javax.inject.Inject
import kotlinx.coroutines.CoroutineDispatcher
Expand All @@ -23,19 +24,48 @@ class ChartRepositoryImpl @Inject constructor(
@Dispatcher(ChartTrainerDispatcher.IO)
private val dispatcher: CoroutineDispatcher
) : ChartRepository {
override suspend fun fetchNewChartRandomly(): Chart =
override suspend fun fetchNewChartRandomly(totalTurn: Int): Chart =
withContext(dispatcher) {
val chart = networkDataSource
.getChart(
ticker = chartRequestArgumentHelper.getRandomTicker(),
tickUnit = appPreferences.getTickUnit(),
from = chartRequestArgumentHelper.getFromDate(),
to = chartRequestArgumentHelper.getToDate()
)
.asDomainModel(TickUnit.DAY)

val chartId = localDBDataSource.insertChart(chart.asEntity())
localDBDataSource.insertTicks(chart.ticks.map { it.asEntity(chartId = chartId) })
chart.copy(id = chartId)
fetchNewChartRandomlyWithRetry(
currentRetryCount = 0,
totalTurn = totalTurn
)
}

private suspend fun fetchNewChartRandomlyWithRetry(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

설마 S&P500에 1년미만인 종목이 있을까 하며 처리 안하고 넘어갔던 부분인데, TotalTurn 보다 틱수가 낮으면 게임이 불가하여 TotalTurn 보다는 큰 게 나올 때까지 시도하되 무한 루프를 돌면 안되니 최대 횟수를 두었습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Q; 이 역할을 가진 함수를 분리한 이유가 있나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

fetchNewChartRandomly 함수를 사용하는 입장에서 알 필요 없는 currentRetryCount 같은 파라미터를 노출하지 않고 withContext 로 디스패칭을 바꿔주는 것을 한 번만 할 수 있어서 분리했습니다.

currentRetryCount: Int,
totalTurn: Int
): Chart {
// RETRY_COUNT 만큼 시도했는데도 실패하면 IllegalStateException 발생.
// 거의 발생하지 않아도 안전망 역할
if (currentRetryCount > RETRY_COUNT) {
throw ChartGameException.HardToFetchTradeException
}

val dto = networkDataSource
.getChart(
ticker = chartRequestArgumentHelper.getRandomTicker(),
tickUnit = appPreferences.getTickUnit(),
from = chartRequestArgumentHelper.getFromDate(),
to = chartRequestArgumentHelper.getToDate()
)

// 서버에서 가져온 차트가 totalTurn 보다 작으면 다시 요청
if ((dto.ticks?.size ?: 0) < totalTurn) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit; 옵셔널을 바로 해제해서 푸는 것보다 위에서 ticks가 널일 때 핸들링 하는 코드를 넣는게 어떨까요?
nit2; dto의 ticks를 널 대신 빈 리스트로 초기화 하는 것은 어떨까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

넵 두 번째 방법이 좋다고 생각합니다. 불필요한 null 핸들링이라고 생각합니다. 감사합니다!

return fetchNewChartRandomlyWithRetry(
currentRetryCount = currentRetryCount + 1,
totalTurn = totalTurn
)
}

val chart = dto.asDomainModel(TickUnit.DAY)

val chartId = localDBDataSource.insertChart(chart.asEntity())
localDBDataSource.insertTicks(chart.ticks.map { it.asEntity(chartId = chartId) })
return chart.copy(id = chartId)
}

companion object {
private const val RETRY_COUNT = 3
}
}
4 changes: 2 additions & 2 deletions domain/src/main/java/com/yessorae/domain/common/Result.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.yessorae.domain.common
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEmpty
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart

sealed class Result<out T> {
Expand Down Expand Up @@ -31,7 +31,7 @@ private fun <T> Flow<T>.mapToSuccessResult(): Flow<Result<T>> {
}

private fun Flow<Result<Unit>>.emitSuccessResultOnEmpty(): Flow<Result<Unit>> {
return this.onEmpty {
return this.onCompletion {
emit(Result.Success(data = Unit))
}
}
Expand Down
49 changes: 35 additions & 14 deletions domain/src/main/java/com/yessorae/domain/entity/ChartGame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,29 +21,50 @@ data class ChartGame(
// 유저의 게임 강제 종료 여부
val isQuit: Boolean
) {
val totalProfit: Money = currentBalance - startBalance

val rateOfProfit: Double = (totalProfit / startBalance).value * 100

val tradeCount: Int
get() = trades.size

val totalCommission: Money = Money(trades.sumOf { trade -> trade.commission.value })

val visibleTicks: List<Tick> =
chart.ticks
.sortedBy { it.startTimestamp }
.subList(0, chart.ticks.size - totalTurn + currentTurn - 1)
Copy link
Collaborator

Choose a reason for hiding this comment

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

Q; subList의 인덱스가 항상 범위 내에서 동작할까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

결론부터 말씀드리자면, 여기서도 범위 핸들링을하는 게 좋겠습니다. (보여줄 수 있는 만큼만 보여주기)

물론 현재 브랜치부터는 Tick 개수가 totalTurn 보다 작으면 에러를 내리게 해서 동작합니다.
따라서 현재 코드상으로는 코멘트 주신 코드로 에러가 발생할 일은 없습니다.

하지만 생각해보니 애초에 N번 재시도 하고 그래도 Tick 개수가 totalTurn 보다 적으면 에러 내리는 방식이 좋은 UX는 아닌 것 같네요

그래서 해결방법을 두 가지 생각해봤는데요

  1. 운영으로 푼다. 상장한지 3년 이상인 종목만 랜덤풀에 두고 차트 데이터를 가져온다.
  2. 부족한 대로 그 만큼만 보여준다. (예를들어 유저가 설정한 totalTurn이 100인데 봉이 100개가 아닌 78개라면 78개만 보여준다.)

결론은 두 가지 다 적용하자 입니다.

운영으로 풀고 혹여나 실수가 일어나도 무작정 에러를 내려주는 일은 막자는 생각입니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

운영적인 것은 천천히 적용하고 2번을 먼저 적용했습니다.


val ownedStockCount = trades.sumOf { trade -> trade.count }
val ownedStockCount = trades.sumOf { trade ->
if (trade.type.isBuy()) {
trade.count
} else {
-trade.count
}
}

private val ownedTotalStockPrice = trades.sumOf { trade ->
if (trade.type.isBuy()) {
trade.totalTradeMoney.value
} else {
-(trade.totalTradeMoney.value)
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit; 불필요한 소괄호인 것 같습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

앗 제거 하겠습니다. 감사합니다.

}
}

val ownedAverageStockPrice = if (ownedStockCount != 0) {
Money(ownedTotalStockPrice / ownedStockCount)
} else {
Money(0.0)
}

private val ownedTotalStockPrice = trades.sumOf { trade -> trade.totalTradeMoney.value }
private val currentClosePrice: Money = (visibleTicks.lastOrNull()?.closePrice ?: Money(0.0))

val ownedAverageStockPrice = Money(ownedTotalStockPrice / ownedStockCount)
val totalProfit: Money = if (ownedStockCount != 0) {
currentClosePrice - ownedAverageStockPrice
} else {
Money(0.0)
}

val rateOfProfit: Double = if (ownedStockCount != 0) {
(totalProfit / ownedAverageStockPrice).value * 100
} else {
0.0
}

val currentStockPrice: Money = visibleTicks.lastOrNull()?.closePrice ?: Money(0.0)

val currentGameProgress: Float = currentTurn / totalTurn.toFloat() * 100f
val currentGameProgress: Float = currentTurn / totalTurn.toFloat()

// 게임 모든 턴을 끝까지 완료한 경우 true
val isGameComplete: Boolean = currentTurn == totalTurn
Expand All @@ -70,7 +91,7 @@ data class ChartGame(
internal fun copyFrom(newTrade: Trade): ChartGame {
return copy(
trades = trades + newTrade,
currentBalance = currentBalance + newTrade.profit
currentBalance = currentBalance - newTrade.totalTradeMoney
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,8 @@ package com.yessorae.domain.entity.trade

enum class TradeType {
Buy,
Sell
Sell;

fun isBuy(): Boolean = this == Buy
fun isSell(): Boolean = this == Sell
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ sealed class ChartGameException(override val message: String = "") : Exception(m
override val message: String
) : ChartGameException(message = message)

// 설정한 제한 보다 많은 재시도를 했음에도 조건에 맞는 차트를 찾지 못했을 때 발생하는 예외
object HardToFetchTradeException : ChartGameException("")

data class CanNotChangeTradeException(
override val message: String
) : ChartGameException(message = message)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,5 @@ package com.yessorae.domain.repository
import com.yessorae.domain.entity.Chart

interface ChartRepository {
suspend fun fetchNewChartRandomly(): Chart
suspend fun fetchNewChartRandomly(totalTurn: Int): Chart
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import com.yessorae.domain.common.delegateEmptyResultFlow
import com.yessorae.domain.exception.ChartGameException
import com.yessorae.domain.repository.ChartGameRepository
import com.yessorae.domain.repository.ChartRepository
import com.yessorae.domain.repository.UserRepository
import javax.inject.Inject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow

class ChangeChartUseCase @Inject constructor(
private val userRepository: UserRepository,
private val chartRepository: ChartRepository,
private val chartGameRepository: ChartGameRepository
) {
Expand All @@ -25,7 +27,9 @@ class ChangeChartUseCase @Inject constructor(

chartGameRepository.updateChartGame(
chartGame = oldChartGame.copyFrom(
newChart = chartRepository.fetchNewChartRandomly()
newChart = chartRepository.fetchNewChartRandomly(
totalTurn = userRepository.fetchTotalTurnConfig()
)
)
)
}.delegateEmptyResultFlow()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ class SubscribeChartGameUseCase @Inject constructor(
) {
suspend operator fun invoke(gameId: Long?): Flow<Result<ChartGame>> {
if (gameId == null) {
val totalTurn = userRepository.fetchTotalTurnConfig()

val newGameId = chartGameRepository.createNewChartGame(
chartGame = ChartGame.new(
chart = chartRepository.fetchNewChartRandomly(),
totalTurn = userRepository.fetchTotalTurnConfig(),
chart = chartRepository.fetchNewChartRandomly(totalTurn = totalTurn),
totalTurn = totalTurn,
startBalance = userRepository.fetchCurrentBalance()
)
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ class UpdateNextTickUseCase @Inject constructor(
) {
operator fun invoke(gameId: Long): Flow<Result<Unit>> =
flow<Nothing> {
val newChartGame = chartGameRepository.fetchChartGame(gameId = gameId).getNextTurn()
val oldChartGame = chartGameRepository.fetchChartGame(gameId = gameId)

if (newChartGame.isGameEnd) {
if (oldChartGame.isGameEnd) {
throw ChartGameException.CanNotUpdateNextTickException(
message = "can't update next tick because game has been end"
)
}

chartGameRepository.updateChartGame(chartGame = newChartGame)
chartGameRepository.updateChartGame(chartGame = oldChartGame.getNextTurn())
}.delegateEmptyResultFlow()
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.yessorae.presentation.ui.chartgame.ChartGameScreen
import com.yessorae.presentation.ui.designsystem.theme.ChartTrainerTheme
import dagger.hilt.android.AndroidEntryPoint

Expand All @@ -24,7 +25,7 @@ class MainActivity : ComponentActivity() {
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
Greeting("Android")
ChartGameScreen()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,46 +21,24 @@ import com.yessorae.presentation.ui.chartgame.component.ChartGameTopAppBarUi
import com.yessorae.presentation.ui.chartgame.component.order.TradeOrderUi
import com.yessorae.presentation.ui.chartgame.model.ChartGameEvent
import com.yessorae.presentation.ui.chartgame.model.ChartGameScreenUserAction
import com.yessorae.presentation.ui.designsystem.component.ChartTrainerLoadingProgressBar
import com.yessorae.presentation.ui.designsystem.util.showToast
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.collectLatest

@Composable
fun ChartGameScreen(viewModel: ChartGameViewModel = viewModel()) {
val state by viewModel.screenState.collectAsState()
val context = LocalContext.current

LaunchedEffect(key1 = Unit) {
viewModel.screenEvent.collectLatest { event ->
when (event) {
is ChartGameEvent.InputBuyingStockCount -> {
context.showToast(context.getString(R.string.chart_game_toast_trade_buy))
}

is ChartGameEvent.InputSellingStockCount -> {
context.showToast(context.getString(R.string.chart_game_toast_trade_sell))
}

is ChartGameEvent.TradeFail -> {
context.showToast(context.getString(R.string.chart_game_toast_trade_fail))
}

is ChartGameEvent.MoveToBack -> {
// TODO::LATER #23-navigation 셋업과 함께 추가될 내용
}
ChartGameEventHandler(screenEvent = viewModel.screenEvent)

is ChartGameEvent.MoveToTradeHistory -> {
// TODO::LATER #6-트레이드 내역 확인 기능과 #23-navigation 셋업과 함께 추가될 내용
}
}
}
}
val state by viewModel.screenState.collectAsState()

Scaffold(
topBar = {
ChartGameTopAppBarUi(
isStart = state.isStart,
isBeforeStart = state.isBeforeStart,
totalProfit = state.totalProfit,
totalRateOfProfit = state.rateOfProfit,
enableChangeChartButton = state.enableChangeChartButton,
onClickNewChartButton = {
state.onUserAction(ChartGameScreenUserAction.ClickNewChartButton)
},
Expand Down Expand Up @@ -109,14 +87,58 @@ fun ChartGameScreen(viewModel: ChartGameViewModel = viewModel()) {
)
}

TradeOrderUi(
modifier = Modifier.fillMaxWidth(),
tradeOrderUi = state.tradeOrderUi
TradeOrderUi(tradeOrderUi = state.tradeOrderUi)

ChartTrainerLoadingProgressBar(
modifier = Modifier.fillMaxSize(),
show = state.showLoading
)
}
}
}

@Composable
private fun ChartGameEventHandler(screenEvent: SharedFlow<ChartGameEvent>) {
val context = LocalContext.current
LaunchedEffect(key1 = Unit) {
screenEvent.collectLatest { event ->
when (event) {
is ChartGameEvent.InputBuyingStockCount -> {
context.showToast(context.getString(R.string.chart_game_toast_trade_buy))
Copy link
Collaborator

Choose a reason for hiding this comment

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

nit; 코드의 간결함을 위해 반복되는 코드는 확장함수등으로 만들어 보는 것은 어떨까요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

넵 그게 좋겠습니다. 감사합니다!

}

is ChartGameEvent.InputSellingStockCount -> {
context.showToast(context.getString(R.string.chart_game_toast_trade_sell))
}

is ChartGameEvent.TradeFail -> {
context.showToast(context.getString(R.string.chart_game_toast_trade_fail))
}

is ChartGameEvent.HardToFetchTrade -> {
// TODO::LATER #5-유저(익명) 정보 확인/수정 기능이 추가되면 Screen 뒤로가기 추가
context.showToast(
context.getString(R.string.chart_game_toast_hard_to_fetch_trade)
)
}

is ChartGameEvent.GameHasEnded -> {
// TODO::LATER #5-유저(익명) 정보 확인/수정 기능이 추가되면 Screen 뒤로가기로 변경
context.showToast(context.getString(R.string.chart_game_toast_game_has_ended))
}

is ChartGameEvent.MoveToBack -> {
// TODO::LATER #23-navigation 셋업과 함께 추가될 내용
}

is ChartGameEvent.MoveToTradeHistory -> {
// TODO::LATER #6-트레이드 내역 확인 기능과 #23-navigation 셋업과 함께 추가될 내용
}
}
}
}
}

@Preview
@Composable
fun ChartGameScreenPreview() {
Expand Down
Loading
Loading