diff --git a/README.md b/README.md index 86699576c..7056483f9 100644 --- a/README.md +++ b/README.md @@ -1,30 +1,3 @@ -# 미션 - 자판기 - -## 🔍 진행방식 - -- 미션은 **기능 요구사항, 프로그래밍 요구사항, 과제 진행 요구사항** 세 가지로 구성되어 있다. -- 세 개의 요구사항을 만족하기 위해 노력한다. 특히 기능을 구현하기 전에 기능 목록을 만들고, 기능 단위로 커밋 하는 방식으로 진행한다. -- 기능 요구사항에 기재되지 않은 내용은 스스로 판단하여 구현한다. - -## ✉️ 미션 제출 방법 - -- 미션 구현을 완료한 후 GitHub을 통해 제출해야 한다. - - GitHub을 활용한 제출 방법은 [프리코스 과제 제출 문서](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse) 를 참고해 제출한다. -- GitHub에 미션을 제출한 후 [우아한테크코스 지원 플랫폼](https://apply.techcourse.co.kr) 에 접속하여 프리코스 과제를 제출한다. - - 자세한 방법은 [링크](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse#제출-가이드) 를 참고한다. - - **Pull Request만 보내고, 지원 플랫폼에서 과제를 제출하지 않으면 최종 제출하지 않은 것으로 처리되니 주의한다.** - -## ✔️ 과제 제출 전 체크리스트 - 0점 방지 - -- 터미널에서 `java -version`을 실행해 자바 8인지 확인한다. 또는 Eclipse, IntelliJ IDEA와 같은 IDE의 자바 8로 실행하는지 확인한다. -- 터미널에서 맥 또는 리눅스 사용자의 경우 `./gradlew clean test`, 윈도우 사용자의 경우 `gradlew.bat clean test` 명령을 실행했을 때 모든 테스트가 아래와 같이 통과하는지 확인한다. - -``` -BUILD SUCCESSFUL in 0s -``` - ---- - ## 🚀 기능 요구사항 반환되는 동전이 최소한이 되는 자판기를 구현한다. @@ -42,6 +15,60 @@ BUILD SUCCESSFUL in 0s - 사용자가 잘못된 값을 입력할 경우 `IllegalArgumentException`를 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 해당 부분부터 다시 입력을 받는다. - 아래의 프로그래밍 실행 결과 예시와 동일하게 입력과 출력이 이루어져야 한다. +### 상품 + +- 상품 도메인은 이름,가격,수량을 가진다 + +1. 상품 도메인은 필드를 전부 리턴 가능하다 +2. 상품 도메인은 수량을 감소 가능하다 + +### 상품 더미 + +- 상품 더미는 상품 목록을 가진다 + +1. 상품 더미 초기화 시 빈 리스트나 null 이 들어오면 예외가 발생한다. +2. 상품은 상품 이름으로 가격을 리턴 가능하다 +3. 상품 이름으로 재고를 차감 가능하다 +4. 중복된 상품 이름이 있으면 예외이다 + +### 자판기 + +- 코인 더미 존재 +- 상품 더미 존재 +- 사용자 입력 금액 존재 + + +- 자판기는 제품을 살 수 있다 +- 자판기는 남은 금액으로 제품을 더 살 수 있는지 판별 가능하다. +- 자판기는 투입 금액을 리턴 가능하다 +- 자판기는 돌려 줄 잔돈을 리턴 가능하다 + +### 코인 더미 +1. 잔돈 계산은 큰 동전부터 진행한다. 500->100-> ... +2. 사용자가 금액을 입력하면 초기화된다. ex) 660 -> 500 + 100 + 50 + 10 +3. 사용되면 리스트에서 제거 할 필요가 없다. 그냥 되는만큼 돌려준다. + +### exs + +- 10으로 나누어떨어지지 않는 금액이 들어오면 예외이다 +- 입력 미스인 경우 입력을 다시 받는다 + +--- + +# 입출력 + +### 상품 파서 + +- 사용자의 입력을 파싱해서 상품 목록으로 변환한다 +- 정해진 포맷에 맞지 않으면 예외가 발생한다 + - 상품 별 구분자는 ';'여야 한다 + - 상품 내 이름,가격 등의 구분자는 ',' 이다 + - 상품은 '이름, 가격, 수량' 세 가지 항목 모두를 포함해야 한다. + + + + + ### ✍🏻 입출력 요구사항 #### ⌨️ 입력 @@ -108,46 +135,6 @@ BUILD SUCCESSFUL in 0s 50원 - 1개 ``` ---- - -## 🎱 프로그래밍 요구사항 - -- 프로그램을 실행하는 시작점은 `Application`의 `main()`이다. -- JDK 8 버전에서 실행 가능해야 한다. **JDK 8에서 정상 동작하지 않을 경우 0점 처리**한다. -- 자바 코드 컨벤션을 지키면서 프로그래밍한다. - - https://naver.github.io/hackday-conventions-java -- indent(인덴트, 들여쓰기) depth를 3이 넘지 않도록 구현한다. 2까지만 허용한다. - - 예를 들어 while문 안에 if문이 있으면 들여쓰기는 2이다. - - 힌트: indent(인덴트, 들여쓰기) depth를 줄이는 좋은 방법은 함수(또는 메소드)를 분리하면 된다. -- 3항 연산자를 쓰지 않는다. -- 함수(또는 메소드)의 길이가 15라인을 넘어가지 않도록 구현한다. - - 함수(또는 메소드)가 한 가지 일만 잘 하도록 구현한다. -- else 예약어를 쓰지 않는다. - - 힌트: if 조건절에서 값을 return하는 방식으로 구현하면 else를 사용하지 않아도 된다. - - else를 쓰지 말라고 하니 switch/case로 구현하는 경우가 있는데 switch/case도 허용하지 않는다. -- 프로그래밍 요구사항에서 별도로 변경 불가 안내가 없는 경우 파일 수정과 패키지 이동을 자유롭게 할 수 있다. - -### 프로그래밍 요구사항 - Coin - -- Coin 클래스를 활용해 구현해야 한다. -- 필드(인스턴스 변수)인 `amount`의 접근 제어자 private을 변경할 수 없다. - -```java -public enum Coin { - COIN_500(500), - COIN_100(100), - COIN_50(50), - COIN_10(10); - - private final int amount; - - Coin(final int amount) { - this.amount = amount; - } - - // 추가 기능 구현 -} -``` ### 프로그래밍 요구사항 - Randoms, Console @@ -155,13 +142,3 @@ public enum Coin { - Random 값 추출은 `camp.nextstep.edu.missionutils.Randoms`의 `pickNumberInList()`를 활용한다. - 사용자가 입력하는 값은 `camp.nextstep.edu.missionutils.Console`의 `readLine()`을 활용한다. - 프로그램 구현을 완료했을 때 `src/test/java` 디렉터리의 `ApplicationTest`에 있는 모든 테스트 케이스가 성공해야 한다. **테스트가 실패할 경우 0점 처리한다.** - ---- - -## 📈 과제 진행 요구사항 - -- 미션은 [java-vendingmachine-precourse](https://github.com/woowacourse/java-vendingmachine-precourse) 저장소를 Fork/Clone해 시작한다. -- **기능을 구현하기 전에 java-vendingmachine-precourse/docs/README.md 파일에 구현할 기능 목록을 정리**해 추가한다. -- **Git의 커밋 단위는 앞 단계에서 README.md 파일에 정리한 기능 목록 단위**로 추가한다. - - [AngularJS Commit Message Conventions](https://gist.github.com/stephenparish/9941e89d80e2bc58a153) 참고해 commit log를 남긴다. -- 과제 진행 및 제출 방법은 [프리코스 과제 제출 문서](https://github.com/woowacourse/woowacourse-docs/tree/master/precourse) 를 참고한다. diff --git a/src/main/java/vendingmachine/Application.java b/src/main/java/vendingmachine/Application.java index 9d3be447b..46717a7f4 100644 --- a/src/main/java/vendingmachine/Application.java +++ b/src/main/java/vendingmachine/Application.java @@ -1,7 +1,14 @@ package vendingmachine; +import vendingmachine.presentation.InputView; +import vendingmachine.presentation.OutputView; +import vendingmachine.presentation.VendingMachineController; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + InputView inputView = new InputView(); + OutputView outputView = new OutputView(); + VendingMachineController vendingMachineController = new VendingMachineController(inputView, outputView); + vendingMachineController.run(); } } diff --git a/src/main/java/vendingmachine/Coin.java b/src/main/java/vendingmachine/Coin.java index c76293fbc..64106fc66 100644 --- a/src/main/java/vendingmachine/Coin.java +++ b/src/main/java/vendingmachine/Coin.java @@ -1,16 +1,55 @@ package vendingmachine; +import java.util.ArrayList; +import java.util.List; +import vendingmachine.domain.DomainErrorMessage; + public enum Coin { COIN_500(500), COIN_100(100), COIN_50(50), COIN_10(10); + private static final int MINIMUM_MONEY_VALUE = 100; + private static final int MINIMUM_MONEY_THRESHOLD = 10; // TODO 이 부분 프로덕트와 중복이다. 제거한다. private final int amount; Coin(final int amount) { this.amount = amount; } - // 추가 기능 구현 + public static List getCoinsFrom(int money){ + validateMoney(money); + return generateCoins(money); + } + + private static List generateCoins(int money) { + List coins = new ArrayList<>(); + for (Coin coin : Coin.values()) { + int count = money / coin.amount; + money %= coin.amount; + addCoins(coin, count, coins); + } + return coins; + } + + private static void addCoins(Coin coin, int count, List coins) { + for (int i = 0; i < count; i++) { + coins.add(coin); + } + } + + private static void validateMoney(int money){ + if (money % MINIMUM_MONEY_THRESHOLD != 0 || money < MINIMUM_MONEY_VALUE){ + throw new IllegalArgumentException(DomainErrorMessage.INVALID_MONEY.getMessage()); + } + } + + public int getAmount() { + return amount; + } + + public String customToString(int quantity) { + return amount + "원 - " + quantity + "개"; + } } diff --git a/src/main/java/vendingmachine/domain/Coins.java b/src/main/java/vendingmachine/domain/Coins.java new file mode 100644 index 000000000..0c4ed15c3 --- /dev/null +++ b/src/main/java/vendingmachine/domain/Coins.java @@ -0,0 +1,44 @@ +package vendingmachine.domain; + +import java.util.List; +import java.util.Objects; +import vendingmachine.Coin; + +public class Coins { + private final List coinsForChanges; + + private Coins(List coinsForChanges) { + validate(coinsForChanges); + this.coinsForChanges = coinsForChanges; + } + + private void validate(List coins){ + if (Objects.isNull(coins) || coins.isEmpty()) { + throw new IllegalArgumentException(DomainErrorMessage.INVALID_CHANGES.getMessage()); + } + } + + public static Coins from(int money){ + List coinsFromMoney = Coin.getCoinsFrom(money); + return new Coins(coinsFromMoney); + } + + private int getCoinsSum(){ + return coinsForChanges.stream().mapToInt(Coin::getAmount).sum(); + } + + public List getChanges(int changes){ + if (getCoinsSum() <= changes){ + return coinsForChanges; + } + return Coin.getCoinsFrom(changes); + } + + public void removeAll(List changes) { + coinsForChanges.removeAll(changes); + } + + public List getCoinsForChanges() { + return coinsForChanges; + } +} diff --git a/src/main/java/vendingmachine/domain/DomainErrorMessage.java b/src/main/java/vendingmachine/domain/DomainErrorMessage.java new file mode 100644 index 000000000..9cf743695 --- /dev/null +++ b/src/main/java/vendingmachine/domain/DomainErrorMessage.java @@ -0,0 +1,23 @@ +package vendingmachine.domain; + +public enum DomainErrorMessage { + INVALID_MONEY("가격 형식이 부적절합니다."), + INVALID_BUY_QUANTITY("구매하려는 수량이 재고 수량보다 많습니다."), + EMPTY_STOCK("상품 목록이 없습니다."), + INVALID_CHANGES("잔돈 입력 금액이 부적절합니다."), + INVALID_PRODUCT_NAME("상품 이름이 부적절합니다."), + NOT_ENOUGH_MONEY("상품 구매 금액이 부족합니다."), + + DUPLICATED_NAMES("상품 이름이 중복되었습니다."); + + private static final String ERROR = "[ERROR] "; + private final String message; + + DomainErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return ERROR + message; + } +} diff --git a/src/main/java/vendingmachine/domain/Product.java b/src/main/java/vendingmachine/domain/Product.java new file mode 100644 index 000000000..f208e7e93 --- /dev/null +++ b/src/main/java/vendingmachine/domain/Product.java @@ -0,0 +1,60 @@ +package vendingmachine.domain; + +import java.util.Objects; + +public class Product { + private static final int MINIMUM_MONEY_VALUE = 100; + private static final int MINIMUM_MONEY_THRESHOLD = 10; + private final String name; + private final int price; + private int quantity; + + public Product(String name, int price, int quantity) { + this.name = name; + validateMoney(price); + this.price = price; + this.quantity = quantity; + } + + private void validateMoney(int money){ + if (money % MINIMUM_MONEY_THRESHOLD != 0 || money < MINIMUM_MONEY_VALUE){ + throw new IllegalArgumentException(DomainErrorMessage.INVALID_MONEY.getMessage()); + } + } + + public String getName() { + return name; + } + + public int getPrice() { + return price; + } + + public int getQuantity() { + return quantity; + } + + public void decreaseQuantity(int buyQuantity) { + if (buyQuantity > this.quantity){ + throw new IllegalArgumentException(DomainErrorMessage.INVALID_BUY_QUANTITY.getMessage()); + } + this.quantity -= buyQuantity; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Product)) { + return false; + } + Product product = (Product) o; + return price == product.price && quantity == product.quantity && Objects.equals(name, product.name); + } + + @Override + public int hashCode() { + return Objects.hash(name, price, quantity); + } +} diff --git a/src/main/java/vendingmachine/domain/Products.java b/src/main/java/vendingmachine/domain/Products.java new file mode 100644 index 000000000..d49812770 --- /dev/null +++ b/src/main/java/vendingmachine/domain/Products.java @@ -0,0 +1,60 @@ +package vendingmachine.domain; + +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +public class Products { + private final List stocks; + + public Products(List stocks) { + validate(stocks); + this.stocks = stocks; + } + + private void validate(List stocks){ + validateEmpty(stocks); + validateDuplicatedName(stocks); + } + + private void validateEmpty(List stocks){ + if (Objects.isNull(stocks) || stocks.isEmpty()){ + throw new IllegalArgumentException(DomainErrorMessage.EMPTY_STOCK.getMessage()); + } + } + + private void validateDuplicatedName(List stocks){ + Set nameSet = stocks.stream().map(Product::getName).collect(Collectors.toSet()); + if (stocks.size() != nameSet.size()){ + throw new IllegalArgumentException(DomainErrorMessage.DUPLICATED_NAMES.getMessage()); + } + } + + public int getMinimalPrice() { + return stocks.stream() + .mapToInt(Product::getPrice) + .min() + .orElseThrow(() -> new IllegalArgumentException(DomainErrorMessage.EMPTY_STOCK.getMessage())); + } + + public int getQuantitySum() { + return stocks.stream().mapToInt(Product::getQuantity).sum(); + } + + public int getPriceFrom(String productName){ + return getProductByName(productName).getPrice(); + } + + public void decreaseByName(String productName){ + Product productByName = getProductByName(productName); + productByName.decreaseQuantity(1); + } + + private Product getProductByName(String productName) { + return stocks.stream() + .filter(each -> each.getName().equals(productName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException(DomainErrorMessage.INVALID_PRODUCT_NAME.getMessage())); + } +} diff --git a/src/main/java/vendingmachine/domain/RandomCoinGenerator.java b/src/main/java/vendingmachine/domain/RandomCoinGenerator.java new file mode 100644 index 000000000..d97e93030 --- /dev/null +++ b/src/main/java/vendingmachine/domain/RandomCoinGenerator.java @@ -0,0 +1,18 @@ +package vendingmachine.domain; + +import camp.nextstep.edu.missionutils.Randoms; +import java.util.ArrayList; + +public class RandomCoinGenerator { + private RandomCoinGenerator() { + throw new UnsupportedOperationException(); + } + + public static int getRandomChangeAmount(){ + ArrayList numberRanges = new ArrayList<>(); + numberRanges.add(450); + numberRanges.add(550); + numberRanges.add(650); // 이걸 내가 해야한다고라... + return Randoms.pickNumberInList(numberRanges); + } +} diff --git a/src/main/java/vendingmachine/domain/VendingMachine.java b/src/main/java/vendingmachine/domain/VendingMachine.java new file mode 100644 index 000000000..694164dbb --- /dev/null +++ b/src/main/java/vendingmachine/domain/VendingMachine.java @@ -0,0 +1,46 @@ +package vendingmachine.domain; + +import java.util.List; +import vendingmachine.Coin; + +public class VendingMachine { + private final Coins coins; + private final Products products; + private int inputMoney; + + public VendingMachine(Coins coins, Products products, int inputMoney) { + this.coins = coins; + this.products = products; + this.inputMoney = inputMoney; + } + + public void buyProduct(String productName){ + int productPrice = products.getPriceFrom(productName); + if (productPrice > inputMoney){ + throw new IllegalArgumentException(DomainErrorMessage.NOT_ENOUGH_MONEY.getMessage()); + } + inputMoney -= productPrice; + products.decreaseByName(productName); + } + + public boolean canBuy(){ + if (products.getQuantitySum() == 0){ + return false; + } + return products.getMinimalPrice() <= inputMoney; + } + + public int getInputMoney() { + return inputMoney; + } + + public List getChanges(){ + List changes = coins.getChanges(inputMoney); + coins.removeAll(changes); + return changes; + } + + public String getCoinsString(){ + return coins.toString(); + } +} diff --git a/src/main/java/vendingmachine/presentation/CoinsFormatter.java b/src/main/java/vendingmachine/presentation/CoinsFormatter.java new file mode 100644 index 000000000..2993f6a74 --- /dev/null +++ b/src/main/java/vendingmachine/presentation/CoinsFormatter.java @@ -0,0 +1,30 @@ +package vendingmachine.presentation; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import vendingmachine.Coin; + +public class CoinsFormatter { + private final Map coinMap; + + public CoinsFormatter(List coins) { + coinMap = new EnumMap<>(Coin.class); + for (Coin coin : Coin.values()) { + coinMap.put(coin, 0); + } + + coins.forEach(coin -> coinMap.put(coin, coinMap.get(coin) + 1)); + } + + public String format() { + return coinMap.entrySet().stream() + .map(CoinsFormatter::formatEntry) + .collect(Collectors.joining(System.lineSeparator())); + } + + private static String formatEntry(Map.Entry entry) { + return entry.getKey().getAmount() + "원 - " + entry.getValue() + "개"; + } +} diff --git a/src/main/java/vendingmachine/presentation/InputValidator.java b/src/main/java/vendingmachine/presentation/InputValidator.java new file mode 100644 index 000000000..fbd29b27e --- /dev/null +++ b/src/main/java/vendingmachine/presentation/InputValidator.java @@ -0,0 +1,13 @@ +package vendingmachine.presentation; + +public class InputValidator { + private InputValidator() { + throw new UnsupportedOperationException(); + } + + public static void validateInteger(int value){ + if (value < 0){ + throw new IllegalArgumentException(PresentationErrorMessage.INVALID_NUMBER.getMessage()); + } + } +} diff --git a/src/main/java/vendingmachine/presentation/InputView.java b/src/main/java/vendingmachine/presentation/InputView.java new file mode 100644 index 000000000..d6a333be2 --- /dev/null +++ b/src/main/java/vendingmachine/presentation/InputView.java @@ -0,0 +1,32 @@ +package vendingmachine.presentation; + +import camp.nextstep.edu.missionutils.Console; + +public class InputView { + private static final String INPUT_PRODUCT_INFO = "상품명과 가격, 수량을 입력해 주세요."; + private static final String INPUT_MONEY = "투입 금액을 입력해 주세요."; + private static final String COINS_INPUT = "자판기가 보유하고 있는 금액을 입력해 주세요."; + private static final String INPUT_PRODUCT_NAME = "구매할 상품명을 입력해 주세요."; + + public int getChangesInfo(){ + System.out.println(COINS_INPUT); + int coinsValue = Integer.parseInt(Console.readLine()); + InputValidator.validateInteger(coinsValue); + return coinsValue; + } + + public String getProduct(){ + System.out.println(INPUT_PRODUCT_INFO); + return Console.readLine(); + } + + public String getInputMoney(){ + System.out.println(INPUT_MONEY); + return Console.readLine(); + } + + public String getProductName(){ + System.out.println(INPUT_PRODUCT_NAME); + return Console.readLine(); + } +} diff --git a/src/main/java/vendingmachine/presentation/OutputView.java b/src/main/java/vendingmachine/presentation/OutputView.java new file mode 100644 index 000000000..1de3cd103 --- /dev/null +++ b/src/main/java/vendingmachine/presentation/OutputView.java @@ -0,0 +1,19 @@ +package vendingmachine.presentation; + +public class OutputView { + private static final String INPUT_MONEY_REMAIN = "투입 금액: %d원"; + private static final String MACHINE_HOLDING_COINS = "자판기가 보유한 동전"; + + public void printRemainingMoney(int amount){ + System.out.printf((INPUT_MONEY_REMAIN) + "%n", amount); + } + + public void printCoins(String coins){ + System.out.println(coins); + } + + public void printInitialCoins(String coins) { + System.out.println(MACHINE_HOLDING_COINS); + System.out.println(coins); + } +} diff --git a/src/main/java/vendingmachine/presentation/PresentationErrorMessage.java b/src/main/java/vendingmachine/presentation/PresentationErrorMessage.java new file mode 100644 index 000000000..7cacbe917 --- /dev/null +++ b/src/main/java/vendingmachine/presentation/PresentationErrorMessage.java @@ -0,0 +1,19 @@ +package vendingmachine.presentation; + +public enum PresentationErrorMessage { + INVALID_PRODUCT_INPUT_FORMAT("상품 입력 포맷이 부적절합니다."), + INVALID_NUMBER_FORMAT("숫자만 입력 해 주세요"), + INVALID_NUMBER("부적절한 숫자 범위입니다."), + ; + + private static final String ERROR = "[ERROR] "; + private final String message; + + PresentationErrorMessage(String message) { + this.message = message; + } + + public String getMessage() { + return ERROR + message; + } +} diff --git a/src/main/java/vendingmachine/presentation/ProductParser.java b/src/main/java/vendingmachine/presentation/ProductParser.java new file mode 100644 index 000000000..5f56cbefb --- /dev/null +++ b/src/main/java/vendingmachine/presentation/ProductParser.java @@ -0,0 +1,53 @@ +package vendingmachine.presentation; + +import java.util.ArrayList; +import java.util.List; +import vendingmachine.domain.Product; + +public class ProductParser { + private ProductParser() { + throw new UnsupportedOperationException(); + } + + public static List parseInput(String input){ + ArrayList products = new ArrayList<>(); + String[] split = input.split(";"); + for (String each : split) { + Product product = getProduct(each); + products.add(product); + } + return products; + } + + private static Product getProduct(String each) { + String[] namePriceQuantity = getNamePriceQuantity(each); + String name = namePriceQuantity[0]; + String price = namePriceQuantity[1]; + String quantity = namePriceQuantity[2]; + return new Product(name, formatStringToInteger(price), formatStringToInteger(quantity)); + } + + private static int formatStringToInteger(String value){ + try { + return Integer.parseInt(value); + } catch (NumberFormatException e){ + throw new IllegalArgumentException(PresentationErrorMessage.INVALID_NUMBER_FORMAT.getMessage()); + } + } + + private static String[] getNamePriceQuantity(String each) { + if (!each.startsWith("[") || !each.endsWith("]")) { + throw new IllegalArgumentException(PresentationErrorMessage.INVALID_PRODUCT_INPUT_FORMAT.getMessage()); + } + String productData = each.replace("[", "").replace("]", ""); + String[] namePriceQuantity = productData.split(","); + validateProductFormat(namePriceQuantity); + return namePriceQuantity; + } + + private static void validateProductFormat(String[] namePriceQuantity) { + if (namePriceQuantity.length != 3){ + throw new IllegalArgumentException(PresentationErrorMessage.INVALID_PRODUCT_INPUT_FORMAT.getMessage()); + } + } +} diff --git a/src/main/java/vendingmachine/presentation/RetryHandler.java b/src/main/java/vendingmachine/presentation/RetryHandler.java new file mode 100644 index 000000000..fe89753dd --- /dev/null +++ b/src/main/java/vendingmachine/presentation/RetryHandler.java @@ -0,0 +1,30 @@ +package vendingmachine.presentation; + +import java.util.function.Supplier; + +public class RetryHandler { + private RetryHandler() { + throw new UnsupportedOperationException(); + } + + public static T retry(Supplier consumer){ + while (true){ + try { + return consumer.get(); + } catch (IllegalArgumentException e){ + System.out.println(e.getMessage()); + } + } + } + + public static void retry(Runnable consumer){ + while (true){ + try { + consumer.run(); + return; + } catch (IllegalArgumentException e){ + System.out.println(e.getMessage()); + } + } + } +} diff --git a/src/main/java/vendingmachine/presentation/VendingMachineController.java b/src/main/java/vendingmachine/presentation/VendingMachineController.java new file mode 100644 index 000000000..a8390f1ee --- /dev/null +++ b/src/main/java/vendingmachine/presentation/VendingMachineController.java @@ -0,0 +1,59 @@ +package vendingmachine.presentation; + +import java.util.List; +import vendingmachine.domain.Coins; +import vendingmachine.domain.Product; +import vendingmachine.domain.Products; +import vendingmachine.domain.VendingMachine; + +public class VendingMachineController { + private final InputView inputView; + private final OutputView outputView; + + public VendingMachineController(InputView inputView, OutputView outputView) { + this.inputView = inputView; + this.outputView = outputView; + } + + public void run(){ + int changesInfo = RetryHandler.retry(inputView::getChangesInfo); + Products products = RetryHandler.retry(this::initProducts); + int inputMoney = RetryHandler.retry(this::getInputMoney); + Coins coins = RetryHandler.retry(() -> getCoins(changesInfo)); + VendingMachine vendingMachine = RetryHandler.retry(() -> new VendingMachine(coins, products, inputMoney)); + RetryHandler.retry(() -> purchaseProducts(vendingMachine)); + RetryHandler.retry(() -> printRemainingCoins(vendingMachine)); + } + + private Coins getCoins(int changesInfo) { + Coins coins = Coins.from(changesInfo); + CoinsFormatter coinsFormatter = new CoinsFormatter(coins.getCoinsForChanges()); + outputView.printInitialCoins(coinsFormatter.format()); + return coins; + } + + private void purchaseProducts(VendingMachine vendingMachine) { + while (vendingMachine.canBuy()){ + outputView.printRemainingMoney(vendingMachine.getInputMoney()); + String productName = inputView.getProductName(); + vendingMachine.buyProduct(productName); + } + } + + private void printRemainingCoins(VendingMachine vendingMachine) { + CoinsFormatter coinsFormatter = new CoinsFormatter(vendingMachine.getChanges()); + String formattedCoins = coinsFormatter.format(); + outputView.printCoins(formattedCoins); + } + + private Products initProducts(){ + String userInput = inputView.getProduct(); + List products = ProductParser.parseInput(userInput); + return new Products(products); + } + + private int getInputMoney(){ + String userInput = inputView.getInputMoney(); + return Integer.parseInt(userInput); + } +} diff --git a/src/test/java/vendingmachine/CoinTest.java b/src/test/java/vendingmachine/CoinTest.java new file mode 100644 index 000000000..9af901481 --- /dev/null +++ b/src/test/java/vendingmachine/CoinTest.java @@ -0,0 +1,32 @@ +package vendingmachine; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import vendingmachine.domain.DomainErrorMessage; + +class CoinTest { + @DisplayName("금액으로부터 코인 더미를 생성 할 수 있다") + @Test + void getCoinsFrom() { + List coinsFrom = Coin.getCoinsFrom(660); + Assertions.assertThat(coinsFrom).containsExactly( + Coin.COIN_500, Coin.COIN_100, Coin.COIN_50, Coin.COIN_10 + ); + } + + @DisplayName("부적절한 금액이 주어지면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(ints = {0, 99, 101}) + void getCoinsFrom_invalidMoney(int invalidMoney) { + assertThatThrownBy(() -> Coin.getCoinsFrom(invalidMoney)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(DomainErrorMessage.INVALID_MONEY.getMessage()); + + } +} diff --git a/src/test/java/vendingmachine/domain/CoinsTest.java b/src/test/java/vendingmachine/domain/CoinsTest.java new file mode 100644 index 000000000..a9a50ada9 --- /dev/null +++ b/src/test/java/vendingmachine/domain/CoinsTest.java @@ -0,0 +1,57 @@ +package vendingmachine.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vendingmachine.Coin; + +class CoinsTest { + @DisplayName("초기화에 성공한다") + @Test + void constructTest() { + Coins coins = Coins.from(500); + Assertions.assertThat(coins).isNotNull(); + } + + @DisplayName("부적절한 금액이 주어지면 예외가 발생한다.") + @Test + void constructTest_emptyList() { + assertThatThrownBy(() -> Coins.from(0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(DomainErrorMessage.INVALID_MONEY.getMessage()); + } + + @DisplayName("잔돈이 현재 코인 보유량보다 많으면 보유량 전부를 리턴한다.") + @Test + void getChanges_changeIsBiggerThanCoins() { + Coins coins = Coins.from(660); + List changes = coins.getChanges(1000); + Assertions.assertThat(changes).containsExactly( + Coin.COIN_500, Coin.COIN_100, Coin.COIN_50, Coin.COIN_10 + ); + } + + @DisplayName("잔돈이 현재 코인 보유량보다 적으면 필요량을 계산하여 리턴한다.") + @Test + void getChanges_changeIsSmallerThanCoins() { + Coins coins = Coins.from(1000); + List changes = coins.getChanges(660); + Assertions.assertThat(changes).containsExactly( + Coin.COIN_500, Coin.COIN_100, Coin.COIN_50, Coin.COIN_10 + ); + } + + @DisplayName("사용된 코인은 리스트에서 제거된다.") + @Test + void removeAll() { + Coins coins = Coins.from(1320); + List changes = coins.getChanges(660); + coins.removeAll(changes); + Assertions.assertThat(changes).containsExactly( + Coin.COIN_500, Coin.COIN_100, Coin.COIN_50, Coin.COIN_10 + ); + } +} diff --git a/src/test/java/vendingmachine/domain/ProductTest.java b/src/test/java/vendingmachine/domain/ProductTest.java new file mode 100644 index 000000000..2c711af33 --- /dev/null +++ b/src/test/java/vendingmachine/domain/ProductTest.java @@ -0,0 +1,54 @@ +package vendingmachine.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.assertj.core.api.Assertions; +import org.assertj.core.api.SoftAssertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ProductTest { + @DisplayName("초기화에 성공한다.") + @Test + void constructTest() { + String name = "name"; + int price = 1000; + int quantity = 1; + Product product = new Product(name, price, quantity); + SoftAssertions.assertSoftly( + softly -> { + softly.assertThat(product.getName()).isEqualTo(name); + softly.assertThat(product.getPrice()).isEqualTo(price); + softly.assertThat(product.getQuantity()).isEqualTo(quantity); + } + ); + } + + @DisplayName("부적절한 가격이 입력되면 예외이다") + @ParameterizedTest + @ValueSource(ints = {-1, 0, 99, 101}) + void invalid_money(int invalidMoney) { + assertThatThrownBy(() -> new Product("name", invalidMoney, 10)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(DomainErrorMessage.INVALID_MONEY.getMessage()); + } + + @DisplayName("수량을 정상적으로 감소 시킬 수 있다.") + @Test + void decreaseQuantity() { + Product product = new Product("name", 100, 10); + product.decreaseQuantity(10); + Assertions.assertThat(product.getQuantity()).isZero(); + } + + @DisplayName("재고 수량보다 더 많은 량을 감소시키면 예외이다.") + @Test + void decreaseQuantity_invalidBuyQuantity() { + Product product = new Product("name", 100, 10); + assertThatThrownBy(() -> product.decreaseQuantity(11)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(DomainErrorMessage.INVALID_BUY_QUANTITY.getMessage()); + } +} diff --git a/src/test/java/vendingmachine/domain/ProductsTest.java b/src/test/java/vendingmachine/domain/ProductsTest.java new file mode 100644 index 000000000..98a599f87 --- /dev/null +++ b/src/test/java/vendingmachine/domain/ProductsTest.java @@ -0,0 +1,85 @@ +package vendingmachine.domain; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; + +class ProductsTest { + private Products products; + + @BeforeEach + void setUp() { + Product one = new Product("name1", 100, 1); + Product two = new Product("name2", 200, 2); + Product three = new Product("name3", 300, 3); + ArrayList objects = new ArrayList<>(); + objects.add(one); + objects.add(two); + objects.add(three); + products = new Products(Collections.unmodifiableList(objects)); + } + + @DisplayName("초기화에 성공한다.") + @Test + void constructTest() { + Assertions.assertThat(products).isNotNull(); + } + + @DisplayName("상품 목록이 null 이면 예외이다") + @NullSource + @ParameterizedTest + void nullListProvided(List products) { + assertThatThrownBy(() -> new Products(products)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(DomainErrorMessage.EMPTY_STOCK.getMessage()); + } + + @DisplayName("상품 목록이 제공되지 않으면 예외이다") + @Test + void invalid_quantity() { + ArrayList empty = new ArrayList<>(); + assertThatThrownBy(() -> new Products(empty)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(DomainErrorMessage.EMPTY_STOCK.getMessage()); + } + + @DisplayName("중복된 상품 이름이 입력되면 예외이다.") + @Test + void duplicatedNames() { + ArrayList empty = new ArrayList<>(); + empty.add(new Product("name1", 100, 1)); + empty.add(new Product("name1", 200, 2)); + assertThatThrownBy(() -> new Products(empty)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(DomainErrorMessage.DUPLICATED_NAMES.getMessage()); + } + + @DisplayName("'상품 최소 가격'을 도출 가능하다") + @Test + void getMinimalPrice() { + int minimalPrice = products.getMinimalPrice(); + Assertions.assertThat(minimalPrice).isEqualTo(100); + } + + @DisplayName("상품의 총 수량을 도출 가능하다") + @Test + void getQuantitySum(){ + int quantitySum = products.getQuantitySum(); + Assertions.assertThat(quantitySum).isEqualTo(6); + } + + @DisplayName("상품의 이름으로 가격을 불러 올 수 있다.") + @Test + void getPriceFrom() { + int amount = products.getPriceFrom("name1"); + Assertions.assertThat(amount).isEqualTo(100); + } +} diff --git a/src/test/java/vendingmachine/domain/VendingMachineTest.java b/src/test/java/vendingmachine/domain/VendingMachineTest.java new file mode 100644 index 000000000..9f484fa6f --- /dev/null +++ b/src/test/java/vendingmachine/domain/VendingMachineTest.java @@ -0,0 +1,60 @@ +package vendingmachine.domain; + +import java.util.ArrayList; +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vendingmachine.Coin; +import vendingmachine.presentation.ProductParser; + +class VendingMachineTest { + private VendingMachine vendingMachine; + private Coins coins; + private List products; + + @BeforeEach + void setUp() { + coins = Coins.from(1000); + products = ProductParser.parseInput("[콜라,1500,20];[사이다,1000,10]"); + vendingMachine = new VendingMachine(coins, new Products(products), 5000); + } + + @DisplayName("물품을 구매하면 넣은 금액이 차감된다.") + @Test + void buyProduct() { + vendingMachine.buyProduct("콜라"); + Assertions.assertThat(vendingMachine.getInputMoney()).isEqualTo(3500); + } + + @DisplayName("남은 금액이 물품의 최소금액보다 적으면 구매가 불가능하다.") + @Test + void canBuy_notEnoughBudget() { + vendingMachine.buyProduct("콜라"); + vendingMachine.buyProduct("콜라"); + vendingMachine.buyProduct("콜라"); // 남은금액 500원 + Assertions.assertThat(vendingMachine.canBuy()).isFalse(); + } + + @DisplayName("재고가 없으면 구매가 불가능하다.") + @Test + void buyProduct_NotEnoughQuantity() { + ArrayList emptyList = new ArrayList<>(); + Product product = new Product("name", 1000, 0); + emptyList.add(product); + VendingMachine vendingMachine1 = new VendingMachine(coins, new Products(emptyList), 5000); + Assertions.assertThat(vendingMachine1.canBuy()).isFalse(); + } + + @DisplayName("자판기는 잔돈을 계산하여 리턴 가능하다") + @Test + void getChanges() { + vendingMachine = new VendingMachine(coins, new Products(products), 1660); + vendingMachine.buyProduct("사이다"); + List changes = vendingMachine.getChanges(); + Assertions.assertThat(changes).containsExactly( + Coin.COIN_500, Coin.COIN_100, Coin.COIN_50, Coin.COIN_10 + ); + } +} diff --git a/src/test/java/vendingmachine/presentation/ProductParserTest.java b/src/test/java/vendingmachine/presentation/ProductParserTest.java new file mode 100644 index 000000000..0420ab72b --- /dev/null +++ b/src/test/java/vendingmachine/presentation/ProductParserTest.java @@ -0,0 +1,33 @@ +package vendingmachine.presentation; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.List; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import vendingmachine.domain.Product; + +class ProductParserTest { + @DisplayName("사용자의 입력을 파싱해서 상품 목록으로 변환한다.") + @Test + void parseInput() { + String given = "[콜라,1500,20];[사이다,1000,10]"; + List products = ProductParser.parseInput(given); + Product cola = new Product("콜라", 1500, 20); + Product cider = new Product("사이다", 1000, 10); + Assertions.assertThat(products).containsExactly(cola, cider); + } + + @DisplayName("정해진 포맷에 맞지 않으면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"[콜라,1500,20],[사이다,1000,10]", "[콜라,숫자아님,20],[사이다,1000,10]", "[콜라,1000]", "[콜라;1000;10]"}) + void parseInput_invalidDelimiter() { + String given = "[콜라,1500,20],[사이다,1000,10]"; + assertThatThrownBy(() -> ProductParser.parseInput(given)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage(PresentationErrorMessage.INVALID_PRODUCT_INPUT_FORMAT.getMessage()); + } +}