diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..dc00db104 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,58 @@ +# 자판기 + +## 기능 목록 + + +- 자판기가 보유하고 있는 금액 입력 받기 + - 입력값 검증 + - 숫자인지 + - 10의 배수인지 + + +- 자판기가 보유하고 있는 금액으로 동전을 무작위로 생성 + + +- 자판기가 보유하고 있는 동전 출력 + + +- 상품명, 가격, 수량 입력 받기 + - 입력값 검증 + - 숫자인지 + - 100원 이상인지 + - 10원으로 나누어 떨어지는지 + + +- 투입 금액 입력 받기 + - 입력값 검증 + - 숫자인지 + - 양수인지 + + +- 구매할 상품 입력 받기 + + +- 입력 받은 상품 구매 가능 여부 확인 + - 상품의 품절 여부 + - 잔액이 충분한지 + - 판매하는 상품인지 + + +- 상품 구매 + - 남은 투입금액 계산 + - 남은 상품 수량 계산 + + +- 잔돈 반환 여부 확인 + - 잔돈 반환 + - 남은 금액이 남은 상품의 최저 가격보다 적은 경우 + - 모든 상품이 소진된 경우 + - 잔돈 반환 X + - 상품 추가 구매 + + +- 잔돈 계산 + - 잔돈 전체 반환 가능 여부 확인 + - 잔돈이 자판기 보유금액보다 금액이 작은 경우 > 전체 반환 + - 잔돈이 자판기 보유금액보다 금액이 큰 경우 > 잔돈으로 반환할 수 있는 금액(보유금액)만 반환 + +- 잔돈 출력 diff --git a/src/main/java/vendingmachine/Application.java b/src/main/java/vendingmachine/Application.java index 9d3be447b..c41cd8251 100644 --- a/src/main/java/vendingmachine/Application.java +++ b/src/main/java/vendingmachine/Application.java @@ -1,7 +1,12 @@ package vendingmachine; +import vendingmachine.controller.VendingMachineController; + + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + VendingMachineController vendingMachineController=new VendingMachineController(); + vendingMachineController.setVendingMachine(); + vendingMachineController.runVendingMachine(); } } diff --git a/src/main/java/vendingmachine/Coin.java b/src/main/java/vendingmachine/Coin.java index c76293fbc..d654f754c 100644 --- a/src/main/java/vendingmachine/Coin.java +++ b/src/main/java/vendingmachine/Coin.java @@ -12,5 +12,17 @@ public enum Coin { this.amount = amount; } - // 추가 기능 구현 + public int get() { + return amount; + } + + public static Coin getEqualCoin(int amount) { + for (Coin coin : Coin.values()) { + if (coin.amount == amount) { + return coin; + } + } + return null; + } + } diff --git a/src/main/java/vendingmachine/controller/VendingMachineController.java b/src/main/java/vendingmachine/controller/VendingMachineController.java new file mode 100644 index 000000000..067f7f948 --- /dev/null +++ b/src/main/java/vendingmachine/controller/VendingMachineController.java @@ -0,0 +1,80 @@ +package vendingmachine.controller; + +import vendingmachine.model.Balance; +import vendingmachine.model.Product; +import vendingmachine.model.Validator; +import vendingmachine.view.InputView; +import vendingmachine.view.OutputView; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static vendingmachine.model.Validator.validateProduct; + +public class VendingMachineController { + + private static Balance balance; + private static Map products; + private static int amountOfInput; + + public void setVendingMachine() { + balance = new Balance(InputView.readBalance()); + OutputView.printBalanceCoin(balance.createCoin()); + saveProducts(); + amountOfInput = InputView.readAmountOfInput(); + OutputView.printAmountOfInput(amountOfInput); + } + + public void runVendingMachine() { + while (canBuy()) { + buy(); + OutputView.printAmountOfInput(amountOfInput); + } + OutputView.printChange(balance.calculateChangeCoin(amountOfInput)); + } + + private boolean canBuy() { + List leftProductsPrice = new ArrayList(); + for (Map.Entry entry : products.entrySet()) { + if (entry.getValue().stockIsLeft()) { + leftProductsPrice.add(entry.getValue().getPrice()); + } + } + if (leftProductsPrice.isEmpty()) { + return false; + } + return amountOfInput >= leftProductsPrice.stream().mapToInt(Integer::intValue).min().getAsInt(); + } + + + public void saveProducts() throws IllegalArgumentException { + products = new HashMap<>(); + try { + String[] str = InputView.readProductInfo().split(";"); + for (String s : str) { + String[] productInfo = s.substring(1, s.length() - 1).split(","); + validateProduct(productInfo, products); + products.put(productInfo[0], new Product(productInfo[1], productInfo[2])); + } + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + saveProducts(); + } + } + + + private void buy() { + try { + String buyingProduct = InputView.readBuyingProduct(); + Validator.validateBuyingProduct(buyingProduct, products, amountOfInput); + amountOfInput -= products.get(buyingProduct).getPrice(); + products.get(buyingProduct).reduceAmount(); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + buy(); + } + } +} + diff --git a/src/main/java/vendingmachine/model/Balance.java b/src/main/java/vendingmachine/model/Balance.java new file mode 100644 index 000000000..5157f8f59 --- /dev/null +++ b/src/main/java/vendingmachine/model/Balance.java @@ -0,0 +1,52 @@ +package vendingmachine.model; + +import camp.nextstep.edu.missionutils.Randoms; +import vendingmachine.Coin; + +import java.util.ArrayList; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +public class Balance { + private final int balance; + List numbers = new ArrayList<>(); + private final Map coins = new EnumMap<>(Coin.class); + private final Map changeCoins = new EnumMap<>(Coin.class); + + public Balance(int balance) { + this.balance = balance; + for (Coin coin : Coin.values()) { + numbers.add(coin.get()); + coins.put(coin, 0); + } + } + + public Map createCoin() { + int tmp = balance; + + while (tmp > 0) { + int randomCoinAmount = Randoms.pickNumberInList(numbers); + if (tmp >= randomCoinAmount) { + Coin key = Coin.getEqualCoin(randomCoinAmount); + coins.put(key, coins.get(key) + 1); + tmp = tmp - randomCoinAmount; + } + } + return coins; + } + + public Map calculateChangeCoin(int change) { + if (change > balance) { + return coins; + } + for (Coin coin : Coin.values()) { + int numberOfBalanceCoin = coins.get(coin); + int neededCoins = change / coin.get(); + int inputCoins = Math.min(neededCoins, numberOfBalanceCoin); + changeCoins.put(coin, inputCoins); + change -= coin.get() * inputCoins; + } + return changeCoins; + } +} diff --git a/src/main/java/vendingmachine/model/Price.java b/src/main/java/vendingmachine/model/Price.java new file mode 100644 index 000000000..43fd330a2 --- /dev/null +++ b/src/main/java/vendingmachine/model/Price.java @@ -0,0 +1,16 @@ +package vendingmachine.model; + +import static vendingmachine.model.Validator.validateNum; +import static vendingmachine.model.Validator.validatePrice; + +public class Price { + private final int price; + Price(String price) { + this.price = validateNum(price); + validatePrice(this.price); + } + + public int get() { + return price; + } +} diff --git a/src/main/java/vendingmachine/model/Product.java b/src/main/java/vendingmachine/model/Product.java new file mode 100644 index 000000000..7c127cf69 --- /dev/null +++ b/src/main/java/vendingmachine/model/Product.java @@ -0,0 +1,27 @@ +package vendingmachine.model; + +import static vendingmachine.model.Validator.validateNegative; +import static vendingmachine.model.Validator.validateNum; + +public class Product { + private final Price price; + private int amount; + + public Product(String price, String amount) throws IllegalArgumentException { + this.price = new Price(price); + this.amount = validateNum(amount); + validateNegative(this.amount); + } + + public boolean stockIsLeft() { + return amount > 0; + } + + public void reduceAmount() { + amount--; + } + + public int getPrice() { + return price.get(); + } +} diff --git a/src/main/java/vendingmachine/model/Validator.java b/src/main/java/vendingmachine/model/Validator.java new file mode 100644 index 000000000..8977a8617 --- /dev/null +++ b/src/main/java/vendingmachine/model/Validator.java @@ -0,0 +1,81 @@ +package vendingmachine.model; + +import java.util.Map; + +import static vendingmachine.util.NumberConsts.MIN_PRICE; +import static vendingmachine.util.NumberConsts.MIN_UNIT; +import static vendingmachine.util.NumberConsts.NUMBER_OF_ELEMENTS; +import static vendingmachine.util.message.ErrorMessage.INVALID_FORMAT; +import static vendingmachine.util.message.ErrorMessage.INVALID_PRICE; +import static vendingmachine.util.message.ErrorMessage.INVALID_UNIT; +import static vendingmachine.util.message.ErrorMessage.IS_NEGATIVE; +import static vendingmachine.util.message.ErrorMessage.NOT_NUMBER; +import static vendingmachine.util.message.ProductErrorMessage.INSUFFICIENT_BALANCE; +import static vendingmachine.util.message.ProductErrorMessage.INVALID_NAME; +import static vendingmachine.util.message.ProductErrorMessage.NOT_FOR_SALE; +import static vendingmachine.util.message.ProductErrorMessage.OUT_OF_STOCK; + +public class Validator { + + public static int validateBalance(String input) throws IllegalArgumentException { + int balance = validateNum(input); + validateNegative(balance); + validateInvalidUnit(balance); + return balance; + } + + public static int validateAmountOfInput(String input) throws IllegalArgumentException { + int amountOfInput = validateNum(input); + validateNegative(amountOfInput); + return amountOfInput; + } + + public static void validatePrice(int price) { + if (price < MIN_PRICE) { + throw new IllegalArgumentException(INVALID_PRICE.fullMessage()); + } + validateInvalidUnit(price); + } + + public static void validateProduct(String[] productInfo, Map products) throws IllegalArgumentException { + if (productInfo.length != NUMBER_OF_ELEMENTS) { + throw new IllegalArgumentException(INVALID_FORMAT.fullMessage()); + } + if (products.containsKey(productInfo[0])) { + throw new IllegalArgumentException(INVALID_NAME.fullMessage()); + } + } + + public static void validateBuyingProduct(String buyingProduct, Map products, + int amountOfInput) throws IllegalArgumentException { + if (!products.containsKey(buyingProduct)) { + throw new IllegalArgumentException(NOT_FOR_SALE.fullMessage()); + } + if (products.get(buyingProduct).getPrice() > amountOfInput) { + throw new IllegalArgumentException(INSUFFICIENT_BALANCE.fullMessage()); + } + if (!products.get(buyingProduct).stockIsLeft()) { + throw new IllegalArgumentException(OUT_OF_STOCK.fullMessage()); + } + } + + public static int validateNum(String input) throws IllegalArgumentException { + try { + return Integer.parseInt(input); + } catch (NumberFormatException e) { + throw new IllegalArgumentException(NOT_NUMBER.fullMessage()); + } + } + + public static void validateNegative(int num) throws IllegalArgumentException { + if (num < 0) { + throw new IllegalArgumentException(IS_NEGATIVE.fullMessage()); + } + } + + public static void validateInvalidUnit(int num) throws IllegalArgumentException { + if (num % MIN_UNIT != 0) { + throw new IllegalArgumentException(INVALID_UNIT.fullMessage()); + } + } +} diff --git a/src/main/java/vendingmachine/util/NumberConsts.java b/src/main/java/vendingmachine/util/NumberConsts.java new file mode 100644 index 000000000..35951c73d --- /dev/null +++ b/src/main/java/vendingmachine/util/NumberConsts.java @@ -0,0 +1,7 @@ +package vendingmachine.util; + +public class NumberConsts { + public static final int MIN_PRICE = 100; + public static final int MIN_UNIT = 10; + public static final int NUMBER_OF_ELEMENTS = 3; +} diff --git a/src/main/java/vendingmachine/util/message/ErrorMessage.java b/src/main/java/vendingmachine/util/message/ErrorMessage.java new file mode 100644 index 000000000..51ceec9e1 --- /dev/null +++ b/src/main/java/vendingmachine/util/message/ErrorMessage.java @@ -0,0 +1,23 @@ +package vendingmachine.util.message; + +import static vendingmachine.util.NumberConsts.MIN_PRICE; +import static vendingmachine.util.NumberConsts.MIN_UNIT; + +public enum ErrorMessage { + ERROR_MESSAGE("[ERROR] "), + INVALID_PRICE(String.format("%d원 이상이어야 합니다.", MIN_PRICE)), + INVALID_UNIT(String.format("%d원으로 나누어떨어져야 합니다.", MIN_UNIT)), + INVALID_FORMAT("형식에 맞지 않습니다."), + NOT_NUMBER("형식에 맞지 않습니다."), + IS_NEGATIVE("음수를 입력할 수 없습니다."); + + private final String message; + + ErrorMessage(String message) { + this.message = message; + } + + public String fullMessage() { + return ERROR_MESSAGE.message + message; + } +} diff --git a/src/main/java/vendingmachine/util/message/InputMessage.java b/src/main/java/vendingmachine/util/message/InputMessage.java new file mode 100644 index 000000000..914bfbdc7 --- /dev/null +++ b/src/main/java/vendingmachine/util/message/InputMessage.java @@ -0,0 +1,21 @@ +package vendingmachine.util.message; + +public enum InputMessage { + + BALANCE("자판기가 보유하고 있는 금액을 입력해 주세요."), + PRODUCT_INFO("\n상품명과 가격, 수량을 입력해 주세요."), + AMOUNT_OF_INPUT("\n투입 금액을 입력해 주세요."), + BUYING_PRODUCT("구매할 상품명을 입력해 주세요."); + + public final String message; + + InputMessage(String message) { + this.message = message; + } + + public String fullMessage() { + return message; + } + +} + diff --git a/src/main/java/vendingmachine/util/message/ProductErrorMessage.java b/src/main/java/vendingmachine/util/message/ProductErrorMessage.java new file mode 100644 index 000000000..822def938 --- /dev/null +++ b/src/main/java/vendingmachine/util/message/ProductErrorMessage.java @@ -0,0 +1,19 @@ +package vendingmachine.util.message; + +public enum ProductErrorMessage { + ERROR_MESSAGE("[ERROR] "), + INVALID_NAME("같은 이름의 상품은 입력할 수 없습니다."), + NOT_FOR_SALE("판매하지 않는 상품입니다."), + INSUFFICIENT_BALANCE("잔액이 부족합니다."), + OUT_OF_STOCK("상품이 품절되었습니다."); + + private final String message; + + ProductErrorMessage(String message) { + this.message = message; + } + + public String fullMessage() { + return ERROR_MESSAGE.message + message; + } +} diff --git a/src/main/java/vendingmachine/view/InputView.java b/src/main/java/vendingmachine/view/InputView.java new file mode 100644 index 000000000..2bd74aa5d --- /dev/null +++ b/src/main/java/vendingmachine/view/InputView.java @@ -0,0 +1,40 @@ +package vendingmachine.view; + +import camp.nextstep.edu.missionutils.Console; + +import static vendingmachine.model.Validator.*; +import static vendingmachine.util.message.InputMessage.*; + +public class InputView { + + public static int readBalance() { + System.out.println(BALANCE.fullMessage()); + try { + return validateBalance(Console.readLine()); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + return readBalance(); + } + } + + public static String readProductInfo() { + System.out.println(PRODUCT_INFO.fullMessage()); + return Console.readLine(); + } + + + public static int readAmountOfInput() { + System.out.println(AMOUNT_OF_INPUT.fullMessage()); + try { + return validateAmountOfInput(Console.readLine()); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + return readAmountOfInput(); + } + } + + public static String readBuyingProduct() { + System.out.println(BUYING_PRODUCT.fullMessage()); + return Console.readLine(); + } +} diff --git a/src/main/java/vendingmachine/view/OutputView.java b/src/main/java/vendingmachine/view/OutputView.java new file mode 100644 index 000000000..5ad2a88ac --- /dev/null +++ b/src/main/java/vendingmachine/view/OutputView.java @@ -0,0 +1,29 @@ +package vendingmachine.view; + +import vendingmachine.Coin; + +import java.util.Map; + +public class OutputView { + + public static void printBalanceCoin(Map coins) { + System.out.println("\n자판기가 보유한 동전"); + + for (Map.Entry entry : coins.entrySet()) { + System.out.println(entry.getKey().get() + "원 - " + entry.getValue() + "개"); + } + } + + public static void printAmountOfInput(int amountOfInput) { + System.out.println("\n투입 금액: " + amountOfInput + "원"); + } + + public static void printChange(Map balanceCoins) { + System.out.println("잔돈"); + for (Map.Entry entry : balanceCoins.entrySet()) { + if (entry.getValue() != 0) { + System.out.println(entry.getKey().get() + "원 - " + entry.getValue() + "개"); + } + } + } +} diff --git a/src/test/java/vendingmachine/ApplicationTest.java b/src/test/java/vendingmachine/ApplicationTest.java index 4bec0f51e..46b6d6a87 100644 --- a/src/test/java/vendingmachine/ApplicationTest.java +++ b/src/test/java/vendingmachine/ApplicationTest.java @@ -1,11 +1,16 @@ package vendingmachine; import camp.nextstep.edu.missionutils.test.NsTest; +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.controller.VendingMachineController; import static camp.nextstep.edu.missionutils.test.Assertions.assertRandomNumberInListTest; import static camp.nextstep.edu.missionutils.test.Assertions.assertSimpleTest; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; class ApplicationTest extends NsTest { private static final String ERROR_MESSAGE = "[ERROR]"; @@ -24,11 +29,13 @@ class ApplicationTest extends NsTest { ); } - @Test - void 예외_테스트() { + @ParameterizedTest + @ValueSource(strings={"-1","25","s"}) + @DisplayName("보유 금액 예외 테스트") + void balanceExceptionTest(String balance) { assertSimpleTest( () -> { - runException("-1"); + runException(balance); assertThat(output()).contains(ERROR_MESSAGE); } ); diff --git a/src/test/java/vendingmachine/CoinTest.java b/src/test/java/vendingmachine/CoinTest.java new file mode 100644 index 000000000..87f8d51cc --- /dev/null +++ b/src/test/java/vendingmachine/CoinTest.java @@ -0,0 +1,26 @@ +package vendingmachine; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import vendingmachine.model.Balance; + +import java.util.Map; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; + +public class CoinTest { + + @ParameterizedTest + @ValueSource(ints = {500, 30410, 391740}) + @DisplayName("동전 생성 테스트") + void createCoinTest(int input) { + Balance balance = new Balance(input); + Map coins = balance.createCoin(); + int sum = 0; + for (Coin coin : Coin.values()) { + sum += coin.get() * coins.get(coin); + } + assertThat(sum).isEqualTo(input); + } +}