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

[벼리] 장바구니 미션 제출합니다. #3

Open
wants to merge 42 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
e402744
docs: step1 요구 사항 목록 작성
gaeun5744 Oct 28, 2024
82ed69c
style: 색상 및 Typography 추가
gaeun5744 Oct 28, 2024
1e97f77
chore: ktlint 형식 추가
gaeun5744 Oct 28, 2024
6037c26
chore: ktlint 플러그인 추가
gaeun5744 Oct 28, 2024
bfcb28c
style: ktlint 적용
gaeun5744 Oct 28, 2024
939223b
chore: coil 라이브러리 추가
gaeun5744 Oct 29, 2024
0ea7b7c
style: dynamicColor 제거 & 배경 색상 설정
gaeun5744 Oct 29, 2024
e69f7b3
style: string 리소스 추가
gaeun5744 Oct 29, 2024
2748474
feat: Product 객체 및 더미데이터 구현
gaeun5744 Oct 29, 2024
324850f
feat: 상품 목록 Ui 구현
gaeun5744 Oct 29, 2024
9b38a6f
feat: 상품 상세 화면 이동 구현
gaeun5744 Oct 29, 2024
83c6f49
refactor: Box -> CenterAlignedTopAppBar로 변경
gaeun5744 Oct 29, 2024
6ff7cdd
style: 색상 추가
gaeun5744 Oct 29, 2024
983456d
feat: context를 이용해 activity를 가져오는 로직 구현
gaeun5744 Oct 29, 2024
5a02f7c
feat: ProductRepository를 이용한 상품 가져오기 로직 구현
gaeun5744 Oct 29, 2024
3032ca5
style: 상품 상세 화면 strings.xml 추가
gaeun5744 Oct 29, 2024
c92353b
fix: repository를 이용해 상품 목록을 가져오도록 로직 수정
gaeun5744 Oct 29, 2024
ce2393f
feat: item 키 값 구현
gaeun5744 Oct 29, 2024
fde0d16
feat: SubmitButton 공용 컴포넌트 구현
gaeun5744 Oct 29, 2024
065e933
feat: 상품 상세 뷰 구현
gaeun5744 Oct 29, 2024
cf7a207
feat: CartActivity 생성
gaeun5744 Oct 29, 2024
9c32c49
feat: CartActivity 이동 로직 구현
gaeun5744 Oct 29, 2024
c45f464
style: ktlint 적용
gaeun5744 Oct 29, 2024
efd1f7e
feat: CartProduct 객체 및 Quantity 객체 구현
gaeun5744 Oct 29, 2024
2dd73f3
feat: CartProductRepository 구현
gaeun5744 Oct 29, 2024
b25b12c
docs: 기능 요구사항 업데이트
gaeun5744 Oct 30, 2024
5683619
refactor: 뒤로가기 Topbar 공용 컴포넌트로 분리
gaeun5744 Nov 1, 2024
71801dd
feat: 전체 가격 계산 로직 구현
gaeun5744 Nov 1, 2024
185e9c0
feat: 최소값보다 작을 경우, 장바구니에서 아이템을 삭제하는 로직 구현
gaeun5744 Nov 1, 2024
365e8be
docs: 기능 요구사항 업데이트
gaeun5744 Nov 1, 2024
d509536
style: Typography 추가
gaeun5744 Nov 1, 2024
2124e76
refactor: submitButton의 파라미터로 label과 onClick 추가
gaeun5744 Nov 1, 2024
0607bc8
style: string 리소스 추가
gaeun5744 Nov 1, 2024
d485aec
feat: 수량 증가 감소 공용 컴포넌트 구현
gaeun5744 Nov 1, 2024
3c03ff0
fix: cartProduct 로직 오류 수정
gaeun5744 Nov 1, 2024
7a5e055
feat: 장바구니 뷰 구현
gaeun5744 Nov 1, 2024
ffed077
style: ktlint 적용
gaeun5744 Nov 1, 2024
703309d
docs: 3단계 기능 요구사항 구현 목록 업데이트
gaeun5744 Nov 1, 2024
9778338
docs: 3단계 프로그래밍 요구사항 작성
gaeun5744 Nov 1, 2024
3de7fe7
test: 장바구니 화면 테스트코드 작성
gaeun5744 Nov 1, 2024
ee05806
docs: 3단계 구현사항 업데이트
gaeun5744 Nov 1, 2024
d5232cf
style: ktlint 적용
gaeun5744 Nov 1, 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
2 changes: 2 additions & 0 deletions .editorConfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[*.{kt,kts}]
ktlint_function_naming_ignore_when_annotated_with=Composable
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,36 @@
# android-shopping-cart
# android-shopping-cart
Copy link
Contributor

Choose a reason for hiding this comment

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

왜 다들 3단계까지만 하시나요 😭
4단계가 찐인데!


# step1

## 기능 요구 사항
- [x] 상품 목록 화면을 구현한다.

## 프로그래밍 요구 사항
- [x] viewModel, Hilt 등은 장바구니 미션에서 활용하지 않는다.
- [x] 상품 목록 화면을 구현할 때 Lazy 컴포넌트를 활용한다.
- [x] 컴포저블 함수가 너무 많은 일을 하지 않도록 분리하기 위해 노력해 본다.
- [x] 의미있는 단위의 함수를 모아 별도의 파일로 분리해본다.

# step2

## 기능 요구 사항
- [x] 상품 상세 화면을 구현한다.
- [x] 상품 목록에서 상품을 누르면 상세 화면으로 이동한다.
- [x] 뒤로 가기 버튼이나 아이콘을 누르면 직전 화면으로 이동한다.
- [x] 장바구니 화면의 빈 껍데기를 연결한다.
- [x] 상품 목록에서 장바구니 아이콘을 누르면 장바구니 화면으로 이동한다.
- [x] 뒤로가기 버튼이나 아이콘을 누르면 직전 화면으로 돌아간다.
- [x] 장바구니에 실제로 상품이 담기는 기능은 이 단계에서 고려하지 않는다.

# step3

## 기능 요구사항
- [x] 상품을 장바구니 담는 기능을 구현한다.
- [x] 장바구니 화면을 구현한다.
- [x] 담긴 상품의 수량을 조절할 수 있어야 한다.
- [x] 수량을 1보다 작게 하면 장바구니에서 상품이 제거된다.
- [x] 담긴 상품 가격의 총합이 주문하기 버튼에 표시된다.

## 프로그래밍 요구사항
- [x] 상품을 주문하는 기능에 대해서는 구현하지 않아도 된다.
- [x] 장바구니 화면에 대한 테스트코드를 작성한다.
4 changes: 3 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.ktlint)
}

android {
Expand All @@ -25,7 +26,7 @@ android {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
"proguard-rules.pro",
)
}
}
Expand Down Expand Up @@ -66,4 +67,5 @@ dependencies {
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)
implementation(libs.bundles.coil)
}
87 changes: 87 additions & 0 deletions app/src/androidTest/java/nextstep/shoppingcart/CartScreenTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package nextstep.shoppingcart

import androidx.compose.ui.test.isDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithContentDescription
import androidx.compose.ui.test.onFirst
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import nextstep.shoppingcart.domain.CartProductRepository
import nextstep.shoppingcart.domain.Product
import nextstep.shoppingcart.ui.cart.CartScreen
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class CartScreenTest {
Copy link
Contributor

Choose a reason for hiding this comment

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

장바구니 화면에서 발생할 수 있는 시나리오를 잘 조합하여 테스트 코드로 만들어주셨네요! (혹시 TDD도 해보셨을까요?)

지금의 테스트는 Android View(XML) 환경에서 작성하는 Activity/Fragment단위 테스트와 크게 다르지 않은데요,

이번 기회에 컴포즈의 함수 단위 테스트 작성 이점을 충분히 누려보시면 좋겠어요.
대표적으로 4단계에서도 재활용될 수 있는 수량 조절 컴포넌트는 별도의 독립된 테스트 시나리오를 적용할 수 있습니다.

@get:Rule
val composeTestRule = createComposeRule()

@Before
fun setUp() {
if (CartProductRepository.cartItems.isEmpty()) {
Copy link
Contributor

Choose a reason for hiding this comment

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

에디, 채드에게도 남긴 코멘트지만, 다음 두 가지를 고민해보시면 좋겠습니다.

  1. Cart 내부 상태가 매 테스트마다 독립적임이 보장되지 않는다. (예를 들어 다른 테스트에서 이미 상품을 담은 상태로 이 테스트를 돌리게 되면 실패) Before 구문을 활용하고는 있지만, 근본적인 원인을 해결할 수 없다.
  2. ShoppingCartScreen이 테스트하기 어렵다(내부에서 상태를 가지고 있다)

핵심은 이번 미션을 통해 상태를 갖는 컴포넌트, 저장소에 의존하는 컴포넌트는 테스트하기 어렵다라는 점을 얻어가셨으면 좋겠습니다 🙂

CartProductRepository.addItem(
Product(
id = 0,
name = "레아 사랑해요",
Copy link
Contributor

Choose a reason for hiding this comment

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

ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ 🙄

imageUrl = "히히",
price = 1004,
),
)
}
composeTestRule.setContent {
CartScreen()
}
}

@Test
fun `수량_증가_버튼을_누르면_상품의_수량이_수량_단위만큼_증가한다`() {
// given
val cartItem = CartProductRepository.cartItems.first()
// when
composeTestRule.onAllNodesWithContentDescription(
"수량 증가 버튼",
).onFirst().performClick()

// then
composeTestRule.onNodeWithText(
(cartItem.quantity.currentValue + cartItem.countInterval).toString(),
Copy link
Contributor

Choose a reason for hiding this comment

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

다른 개발자들도 이 assert문을 한 눈에 보고 이해할 수 있을까요?
다음 글이 도움되길 바랍니다!

http://jojoldu.tistory.com/615?category=1036934

).isDisplayed()
}

@Test
fun `수량_감소_버튼을_누르면_상품의_수량이_수량_단위만큼_감소한다`() {
// given
val cartItem = CartProductRepository.cartItems.first()

// when
composeTestRule.onAllNodesWithContentDescription(
"수량 감소 버튼",
).onFirst().performClick()

// then
composeTestRule.onNodeWithText(
(cartItem.quantity.currentValue - cartItem.countInterval).toString(),
).isDisplayed()
}

@Test
fun `상품의_수량을_증가시키면_변경된_가격이_버튼에_표시된다`() {
// given
val cartItem = CartProductRepository.cartItems.first()
val previousPrice = CartProductRepository.totalPrice()
composeTestRule.onNodeWithText(
"주문하기(${previousPrice}원)",
).isDisplayed()
Copy link
Contributor

Choose a reason for hiding this comment

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

given 절에 assert문이 들어가 있는게 어색하다고 느껴질 것 같아요.
테스트에서 전제 조건을 설정하는 부분은 레벨1에서 배운 내용과 크게 다르지 않습니다.
상태 호이스팅을 활용하여 CartProductRepository을 의존하지 않고도 테스트 가능하게 구현할 수 있습니다.

더 궁극적으로는 상태를 가지고 있는 CartScreen을 한 번에 테스트하기보다, 테스트 가능한 작은 단위의 조각으로 나누어 Stateless 컴포넌트를 테스트해야 합니다.

상태 끌어올리기는 컴포즈를 학습하는 과정에서 계속 연습하시게 될거예요!


// when
composeTestRule.onAllNodesWithContentDescription(
"수량 증가 버튼",
).onFirst().performClick()

// then
composeTestRule.onNodeWithText(
"주문하기(${previousPrice + cartItem.product.price}원)",
).isDisplayed()
}
}
10 changes: 9 additions & 1 deletion app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
Expand All @@ -13,7 +15,13 @@
android:theme="@style/Theme.ShoppingCart"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:name="nextstep.shoppingcart.ui.productdetail.ProductDetailActivity"
android:exported="false" />
<activity
android:name="nextstep.shoppingcart.ui.cart.CartActivity"
android:exported="false" />
<activity
android:name="nextstep.shoppingcart.ui.productlist.ProductListActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.ShoppingCart">
Expand Down
46 changes: 0 additions & 46 deletions app/src/main/java/nextstep/shoppingcart/MainActivity.kt

This file was deleted.

40 changes: 40 additions & 0 deletions app/src/main/java/nextstep/shoppingcart/domain/CartProduct.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package nextstep.shoppingcart.domain

data class CartProduct(
val product: Product,
val quantity: Quantity = Quantity(currentValue = INITIAL_COUNT),
val countInterval: Int = DEFAULT_COUNT_INTERVAL,
) {
val totalPrice: Int = product.price * quantity.currentValue

fun increaseCount(): CartQuantityUpdateResult =
if ((quantity.maxValue != null && quantity.currentValue < quantity.maxValue) || quantity.maxValue == null) {
CartQuantityUpdateResult.Success(
this.copy(
quantity = quantity.copy(currentValue = quantity.currentValue + countInterval),
),
)
} else {
CartQuantityUpdateResult.MaxFail
}

fun decreaseCount(): CartQuantityUpdateResult =
if (quantity.currentValue > quantity.minValue) {
CartQuantityUpdateResult.Success(
this.copy(
quantity = quantity.copy(currentValue = quantity.currentValue - countInterval),
),
)
} else {
CartQuantityUpdateResult.MinFail
}

companion object {
private const val DEFAULT_COUNT_INTERVAL = 1
private const val INITIAL_COUNT = 1
val dummy =
CartProduct(
ProductRepository.dummy.first(),
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package nextstep.shoppingcart.domain

object CartProductRepository {
private val _cartItems: MutableList<CartProduct> = mutableListOf()
val cartItems: List<CartProduct> get() = _cartItems.toList()

fun addItem(product: Product) {
if (_cartItems.all { it.product.id != product.id }) {
_cartItems.add(
CartProduct(
product = product,
),
)
} else {
plusItemCount(product.id)
}
}

fun minusItemCount(productId: Long) {
val cartProduct = findItem(productId)
val index = _cartItems.indexOf(cartProduct)
when (val result = cartProduct.decreaseCount()) {
CartQuantityUpdateResult.MaxFail -> throw IllegalArgumentException("수량 감소시에는 수량 증가 실패 이벤트가 발생할 수 없습니다.")
CartQuantityUpdateResult.MinFail -> deleteCartItem(productId)
is CartQuantityUpdateResult.Success -> _cartItems[index] = result.product
}
}
Comment on lines +19 to +27
Copy link
Contributor

Choose a reason for hiding this comment

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

힌트 코드에 있는 기본 코드를 더 구체화해주신 게 보이네요!
이번 미션에서는 컴포즈에 집중하므로 깊은 피드백을 드리진 않겠습니다 ㅎㅎ


fun plusItemCount(productId: Long): CartQuantityUpdateResult {
val cartProduct = findItem(productId)
val index = _cartItems.indexOf(cartProduct)
val result = cartProduct.increaseCount()
if (result is CartQuantityUpdateResult.Success) {
_cartItems[index] = result.product
}
return result
}

fun deleteCartItem(productId: Long) {
_cartItems.removeIf {
it.product.id == productId
}
}

fun findItem(productId: Long): CartProduct =
_cartItems.firstOrNull {
it.product.id == productId
} ?: throw IllegalArgumentException("장바구니에 $productId 에 대한 상품이 없습니다.")

fun totalPrice(): Int = _cartItems.sumOf { it.totalPrice }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package nextstep.shoppingcart.domain

sealed interface CartQuantityUpdateResult {
data class Success(val product: CartProduct) : CartQuantityUpdateResult

data object MinFail : CartQuantityUpdateResult

data object MaxFail : CartQuantityUpdateResult
}
8 changes: 8 additions & 0 deletions app/src/main/java/nextstep/shoppingcart/domain/Product.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package nextstep.shoppingcart.domain

data class Product(
val id: Long,
val name: String,
val price: Int,
val imageUrl: String,
)
Loading