diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..2fec9c66b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,19 @@ +## 기능 구현 목록 + +- [x] 자판기 보유 금액을 입력한다. + - [x] 10원 단위로 나누어 떨어지지 않을 경우 예외 발생 +- [x] 입력받은 보유 금액에 따라 동전 개수 랜덤 생성 +- [x] 자판기가 보유하고 있는 동전의 개수를 출력한다. +- [x] 자판기에 상품을 입력한다. + - [x] 상품의 가격이 100원이하일 경우 예외 + - [x] 상품의 가격이 10원 단위로 나누어 떨어지지 않을 경우 예외 +- [x] 사용자가 투입할 금액을 입력한다. +- [x] 구매할 상품명 입력 + - [x] 해당 상품이 존재하지 않을 경우 예외 +- [ ] 상품 구매 + - [x] 입력받은 상품이 있는지 확인 + - [x] 금액 계산 + - [x] 보유한 금액이 상품의 가격보다 작은 경우 예외 발생 + - [x] 상품 개수 하나 줄이기 +- [x] 남은 금액이 상품의 최저 가격보다 적거나, 모든 상품이 소진된 경우 종료 + - [x] 잔돈 출력 \ No newline at end of file diff --git a/src/main/java/vendingmachine/Application.java b/src/main/java/vendingmachine/Application.java index 9d3be447b..fdb0a9dd1 100644 --- a/src/main/java/vendingmachine/Application.java +++ b/src/main/java/vendingmachine/Application.java @@ -1,7 +1,10 @@ package vendingmachine; +import vendingmachine.controller.MachineController; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + MachineController controller = new MachineController(); + controller.start(); } } diff --git a/src/main/java/vendingmachine/Coin.java b/src/main/java/vendingmachine/Coin.java deleted file mode 100644 index c76293fbc..000000000 --- a/src/main/java/vendingmachine/Coin.java +++ /dev/null @@ -1,16 +0,0 @@ -package vendingmachine; - -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; - } - - // 추가 기능 구현 -} diff --git a/src/main/java/vendingmachine/constant/Constant.java b/src/main/java/vendingmachine/constant/Constant.java new file mode 100644 index 000000000..afe9e1b77 --- /dev/null +++ b/src/main/java/vendingmachine/constant/Constant.java @@ -0,0 +1,17 @@ +package vendingmachine.constant; + +public enum Constant { + + PRICE_UNIT(10), + MIN_PRICE(100); + + private final int value; + + Constant(int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} diff --git a/src/main/java/vendingmachine/constant/ExceptionMessage.java b/src/main/java/vendingmachine/constant/ExceptionMessage.java new file mode 100644 index 000000000..0ac519b9c --- /dev/null +++ b/src/main/java/vendingmachine/constant/ExceptionMessage.java @@ -0,0 +1,24 @@ +package vendingmachine.constant; + + +public enum ExceptionMessage { + + NOT_EXIST_PRODUCT("존재하지 않는 상품입니다."), + NOT_ENOUGH_MONEY("보유한 금액이 부족해 상품을 구매할 수 없습니다."), + INVALID_UNIT("10원 단위로 입력해야합니다."), + MIN_PRICE("상품의 가격은 100원 이상부터 입력할 수 있습니다."), + INCORRECT_FORMAT("형식에 맞춰 입력해주십시오."), + NOT_INTEGER("숫자로 입력해야 합니다."),; + + private static final String PREFIX = "[ERROR] "; + private final String message; + + ExceptionMessage(String message) { + this.message = message; + } + + @Override + public String toString() { + return PREFIX + message; + } +} \ No newline at end of file diff --git a/src/main/java/vendingmachine/constant/OutputMessage.java b/src/main/java/vendingmachine/constant/OutputMessage.java new file mode 100644 index 000000000..88e42574c --- /dev/null +++ b/src/main/java/vendingmachine/constant/OutputMessage.java @@ -0,0 +1,23 @@ +package vendingmachine.constant; + +public enum OutputMessage { + + READ_MACHINE_CHANGE("자판기가 보유하고 있는 금액을 입력해 주세요."), + CHANGE_MESSAGE("자판기가 보유한 동전"), + CHANGE("%d원 - %d개\n"), + READ_PRODUCT("상품명과 가격, 수량을 입력해 주세요."), + READ_INPUT_AMOUNT("투입 금액을 입력해 주세요."), + INPUT_AMOUNT("투입 금액: %d원"), + PURCHASE_GOODS("구매할 상품명을 입력해 주세요."); + + private final String message; + + OutputMessage(String message) { + this.message = message; + } + + @Override + public String toString() { + return message; + } +} diff --git a/src/main/java/vendingmachine/controller/MachineController.java b/src/main/java/vendingmachine/controller/MachineController.java new file mode 100644 index 000000000..c3b5e31e1 --- /dev/null +++ b/src/main/java/vendingmachine/controller/MachineController.java @@ -0,0 +1,80 @@ +package vendingmachine.controller; + +import vendingmachine.domain.Coins; +import vendingmachine.domain.Money; +import vendingmachine.domain.Product; +import vendingmachine.domain.Products; +import vendingmachine.service.MachineService; +import vendingmachine.view.InputView; +import vendingmachine.view.OutputView; + +import java.util.function.Supplier; + +public class MachineController { + + private final InputView inputView = new InputView(); + private final OutputView outputView = new OutputView(); + private MachineService service; + + public void start() { + service = new MachineService(readChangePrice(), readProducts(), readMoney()); + while (service.isOnSale()) { + purchaseGoods(); + } + outputView.printChange(service.calculateChange()); + } + + private Coins readChangePrice() { + return readInput(() -> { + outputView.printChangeMessage(); + Coins coins = inputView.readChangePrice(); + outputView.printNewLine(); + outputView.printChange(coins); + return coins; + }); + } + + private Products readProducts() { + return readInput(() -> { + outputView.printProduct(); + Products products = inputView.readProducts(); + outputView.printNewLine(); + return products; + }); + } + + private Money readMoney() { + return readInput(() -> { + outputView.printInputAmountMessage(); + Money money = inputView.readInputAmount(); + outputView.printNewLine(); + return money; + }); + } + + private void purchaseGoods() { + readInput(() -> { + outputView.printInputAmount(service.getMoney()); + service.purchaseProduct(readProduct()); + return null; + }); + } + + private Product readProduct() { + return readInput(() -> { + outputView.printPurchaseGoods(); + String productName = inputView.readProduct(); + outputView.printNewLine(); + return service.findProductByName(productName); + }); + } + + private T readInput(Supplier supplier) { + try { + return supplier.get(); + } catch (IllegalArgumentException e) { + outputView.printExceptionMessage(e.getMessage()); + return supplier.get(); + } + } +} diff --git a/src/main/java/vendingmachine/domain/Coin.java b/src/main/java/vendingmachine/domain/Coin.java new file mode 100644 index 000000000..5ba518ac4 --- /dev/null +++ b/src/main/java/vendingmachine/domain/Coin.java @@ -0,0 +1,49 @@ +package vendingmachine.domain; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +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; + } + + public static List getCoinKind() { + return Arrays.stream(Coin.values()) + .map(coin -> coin.amount) + .sorted() + .collect(Collectors.toList()); + } + + public static Coin getCoin(int value) { + return Arrays.stream(Coin.values()) + .filter(coin -> coin.amount == value) + .findAny() + .orElseThrow(() -> new IllegalArgumentException("")); + + } + + public int getPrice() { + return this.amount; + } + + public boolean isChangeable(int price) { + return price > this.amount; + } + + public int calculateTotalAmount(int count) { + return this.amount * count; + } + + public int calculateCoinCount(int money) { + return money % this.amount; + } +} diff --git a/src/main/java/vendingmachine/domain/Coins.java b/src/main/java/vendingmachine/domain/Coins.java new file mode 100644 index 000000000..9d9dc7e3e --- /dev/null +++ b/src/main/java/vendingmachine/domain/Coins.java @@ -0,0 +1,102 @@ +package vendingmachine.domain; + +import camp.nextstep.edu.missionutils.Randoms; +import vendingmachine.constant.Constant; +import vendingmachine.constant.ExceptionMessage; +import vendingmachine.constant.OutputMessage; + +import java.util.EnumMap; +import java.util.Map; + +public class Coins { + + private static final int DEFAULT = 0; + private final Map elements; + + public Coins(int price) { + this.elements = new EnumMap<>(Coin.class); + validatePrice(price); + Coin.getCoinKind() + .forEach(value -> elements.put(Coin.getCoin(value), DEFAULT)); + createRandomCoins(price); + } + + private void validatePrice(int price) { + if (price % Constant.PRICE_UNIT.getValue() != 0) { + throw new IllegalArgumentException(ExceptionMessage.INVALID_UNIT.toString()); + } + } + private void createRandomCoins(int price) { + while (price > 0) { + int value = Randoms.pickNumberInList(Coin.getCoinKind()); + if (value <= price) { + price -= value; + Coin coin = Coin.getCoin(value); + elements.put(coin, elements.get(coin)+1); + } + } + } + + public String getChangeCoins() { + StringBuilder sb = new StringBuilder(); + elements.keySet() + .forEach(value -> { + String message = OutputMessage.CHANGE.toString(); + sb.append(String.format(message, value.getPrice(), elements.get(value))); + } + ); + return sb.toString(); + } + + public String getChange(int money) { + Map change = new EnumMap<>(Coin.class); + calculateChange(change, money); + StringBuilder sb = new StringBuilder(); + return makeScreen(change, sb); + } + + private void calculateChange(Map change, int money) { + for (Coin coin: Coin.values()) { + int coinNumber = returnChange(coin, money, change); + money -= coin.calculateTotalAmount(coinNumber); + if (money <= 0 || isExistChange()) { + break; + } + } + } + + private int returnChange(Coin coin, int money, Map change) { + int coinNumber = 0; + if (coin.isChangeable(money)) { + coinNumber = calculateCoinNumber(money, coin); + elements.put(coin, elements.get(coin) - coinNumber); + change.put(coin, coinNumber); + } + return coinNumber; + } + + private String makeScreen(Map change, StringBuilder sb) { + change.keySet() + .forEach(value -> { + String message = OutputMessage.CHANGE.toString(); + sb.append(String.format(message, value.getPrice(), change.get(value))); + } + ); + return sb.toString(); + } + + private boolean isExistChange() { + return elements.values() + .stream() + .mapToInt(i -> i) + .sum() <= 0; + } + + private int calculateCoinNumber(int money, Coin coin) { + int num = coin.calculateCoinCount(money); + if (elements.get(coin) > num) { + return elements.get(coin); + } + return num; + } +} \ No newline at end of file diff --git a/src/main/java/vendingmachine/domain/Money.java b/src/main/java/vendingmachine/domain/Money.java new file mode 100644 index 000000000..a6b90137b --- /dev/null +++ b/src/main/java/vendingmachine/domain/Money.java @@ -0,0 +1,36 @@ +package vendingmachine.domain; + +import vendingmachine.constant.ExceptionMessage; +import vendingmachine.constant.OutputMessage; + +public class Money { + + private int value; + + public Money(int value) { + this.value = value; + } + + public void pay(int price) { + validateLeftMoney(price); + value -= price; + } + + public void validateLeftMoney(int price) { + if (price > value) { + throw new IllegalArgumentException(ExceptionMessage.NOT_ENOUGH_MONEY.toString()); + } + } + + public boolean isEnoughMoney(int minPrice) { + return value >= minPrice; + } + + public String getInputAmount() { + return String.format(OutputMessage.INPUT_AMOUNT.toString() , value); + } + + public int getValue() { + return value; + } +} diff --git a/src/main/java/vendingmachine/domain/Price.java b/src/main/java/vendingmachine/domain/Price.java new file mode 100644 index 000000000..0854b52b0 --- /dev/null +++ b/src/main/java/vendingmachine/domain/Price.java @@ -0,0 +1,35 @@ +package vendingmachine.domain; + +import vendingmachine.constant.Constant; +import vendingmachine.constant.ExceptionMessage; + +public class Price { + + private final int value; + + public Price(int value) { + validate(value); + this.value = value; + } + + private void validate(int value) { + validateUnit(value); + validateMinPrice(value); + } + + private void validateUnit(int value) { + if (value % Constant.PRICE_UNIT.getValue() != 0) { + throw new IllegalArgumentException(ExceptionMessage.INVALID_UNIT.toString()); + } + } + + private void validateMinPrice(int value) { + if (value < Constant.MIN_PRICE.getValue()) { + throw new IllegalArgumentException(ExceptionMessage.MIN_PRICE.toString()); + } + } + + public int getValue() { + return value; + } +} diff --git a/src/main/java/vendingmachine/domain/Product.java b/src/main/java/vendingmachine/domain/Product.java new file mode 100644 index 000000000..00fd370b9 --- /dev/null +++ b/src/main/java/vendingmachine/domain/Product.java @@ -0,0 +1,48 @@ +package vendingmachine.domain; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +public class Product { + + private static final int NAME_INDEX = 0; + private static final int PRICE_INDEX = 1; + private static final int QUANTITY_INDEX = 2; + private static final String PRODUCT_REGEX = "^\\[|\\]$"; + private static final String PRODUCT_DELIMITER = ","; + private static final String BLANK = ""; + private static final int PURCHASE_QUANTITY = 1; + + private final String name; + private final Price price; + private int quantity; + + public Product(String message) { + List productInformation = parseString(message); + this.name = productInformation.get(NAME_INDEX); + this.price = new Price(Integer.parseInt(productInformation.get(PRICE_INDEX))); + this.quantity = Integer.parseInt(productInformation.get(QUANTITY_INDEX)); + } + + private List parseString(String message) { + String product = message.replaceAll(PRODUCT_REGEX, BLANK); + return Arrays.stream(product.split(PRODUCT_DELIMITER)).collect(Collectors.toList()); + } + + public boolean isSameName(String name) { + return this.name.equals(name); + } + + public void purchase() { + this.quantity -= PURCHASE_QUANTITY; + } + + public boolean isExist() { + return quantity >= PURCHASE_QUANTITY; + } + + public int getPrice() { + return price.getValue(); + } +} diff --git a/src/main/java/vendingmachine/domain/Products.java b/src/main/java/vendingmachine/domain/Products.java new file mode 100644 index 000000000..724fdc32a --- /dev/null +++ b/src/main/java/vendingmachine/domain/Products.java @@ -0,0 +1,35 @@ +package vendingmachine.domain; + +import vendingmachine.constant.ExceptionMessage; + +import java.util.ArrayList; +import java.util.List; + +public class Products { + + private final List elements; + + public Products(List elements) { + this.elements = new ArrayList<>(elements); + } + + public Product findByProductName(String name) { + return elements.stream() + .filter(value -> value.isSameName(name)) + .findAny() + .orElseThrow(() -> new IllegalArgumentException(ExceptionMessage.NOT_EXIST_PRODUCT.toString())); + } + + public boolean isExistProduct() { + return elements.stream() + .anyMatch(Product::isExist); + } + + public int getMinPrice() { + return elements.stream() + .filter(Product::isExist) + .mapToInt(Product::getPrice) + .min() + .orElseThrow(() -> new IllegalArgumentException("")); + } +} diff --git a/src/main/java/vendingmachine/service/MachineService.java b/src/main/java/vendingmachine/service/MachineService.java new file mode 100644 index 000000000..e6023c30a --- /dev/null +++ b/src/main/java/vendingmachine/service/MachineService.java @@ -0,0 +1,40 @@ +package vendingmachine.service; + +import vendingmachine.domain.Coins; +import vendingmachine.domain.Money; +import vendingmachine.domain.Product; +import vendingmachine.domain.Products; + +public class MachineService { + + private final Coins coins; + private final Products products; + private final Money money; + + public MachineService(Coins coins, Products products, Money money) { + this.coins = coins; + this.products = products; + this.money = money; + } + + public Product findProductByName(String name) { + return products.findByProductName(name); + } + + public void purchaseProduct(Product product) { + money.pay(product.getPrice()); + product.purchase(); + } + + public boolean isOnSale() { + return products.isExistProduct() && money.isEnoughMoney(products.getMinPrice()); + } + + public String calculateChange() { + return coins.getChange(money.getValue()); + } + + public Money getMoney() { + return money; + } +} diff --git a/src/main/java/vendingmachine/validator/InputValidator.java b/src/main/java/vendingmachine/validator/InputValidator.java new file mode 100644 index 000000000..b2e85351a --- /dev/null +++ b/src/main/java/vendingmachine/validator/InputValidator.java @@ -0,0 +1,25 @@ +package vendingmachine.validator; + +import vendingmachine.constant.ExceptionMessage; + +import java.util.regex.Pattern; + +public class InputValidator { + + private static final String FORMAT_REGEXP = "^[a-zA-Zㄱ-힣0-9,;\\[\\]]*$"; + private static final String NUMBER_REGEXP = "^\\d*$"; + + public void validateFormat(String input) { + if (!Pattern.matches(FORMAT_REGEXP, input)) { + ExceptionMessage exceptionMessage = ExceptionMessage.INCORRECT_FORMAT; + throw new IllegalArgumentException(exceptionMessage.toString()); + } + } + + public void validateIsNumber(String input) { + if (!Pattern.matches(NUMBER_REGEXP, input)) { + ExceptionMessage exceptionMessage = ExceptionMessage.NOT_INTEGER; + throw new IllegalArgumentException(exceptionMessage.toString()); + } + } +} \ No newline at end of file diff --git a/src/main/java/vendingmachine/view/InputView.java b/src/main/java/vendingmachine/view/InputView.java new file mode 100644 index 000000000..fc6e97af9 --- /dev/null +++ b/src/main/java/vendingmachine/view/InputView.java @@ -0,0 +1,41 @@ +package vendingmachine.view; + +import camp.nextstep.edu.missionutils.Console; +import vendingmachine.domain.Coins; +import vendingmachine.domain.Money; +import vendingmachine.domain.Product; +import vendingmachine.domain.Products; +import vendingmachine.validator.InputValidator; + +import java.util.Arrays; +import java.util.stream.Collectors; + +public class InputView { + + private static final String PRODUCT_DELIMITER = ";"; + private final InputValidator inputValidator = new InputValidator(); + + public Coins readChangePrice() { + String input = Console.readLine(); + inputValidator.validateIsNumber(input); + return new Coins(Integer.parseInt(input)); + } + + public Products readProducts() { + String input = Console.readLine(); + inputValidator.validateFormat(input); + return new Products(Arrays.stream(input.split(PRODUCT_DELIMITER)) + .map(Product::new) + .collect(Collectors.toList())); + } + + public Money readInputAmount() { + String input = Console.readLine(); + inputValidator.validateIsNumber(input); + return new Money(Integer.parseInt(input)); + } + + public String readProduct() { + 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..f5a62ba56 --- /dev/null +++ b/src/main/java/vendingmachine/view/OutputView.java @@ -0,0 +1,45 @@ +package vendingmachine.view; + +import vendingmachine.constant.OutputMessage; +import vendingmachine.domain.Coins; +import vendingmachine.domain.Money; + +public class OutputView { + + public void printChangeMessage() { + System.out.println(OutputMessage.READ_MACHINE_CHANGE); + } + + public void printChange(Coins coins) { + System.out.println(OutputMessage.CHANGE_MESSAGE); + System.out.println(coins.getChangeCoins()); + } + + public void printProduct() { + System.out.println(OutputMessage.READ_PRODUCT); + } + + public void printInputAmountMessage() { + System.out.println(OutputMessage.READ_INPUT_AMOUNT); + } + + public void printInputAmount(Money money){ + System.out.println(money.getInputAmount()); + } + + public void printPurchaseGoods() { + System.out.println(OutputMessage.PURCHASE_GOODS); + } + + public void printChange(String message) { + System.out.println(message); + } + + public void printExceptionMessage(String message){ + System.out.println(message); + } + + public void printNewLine() { + System.out.println(); + } +} diff --git a/src/test/java/vendingmachine/InputValidatorTest.java b/src/test/java/vendingmachine/InputValidatorTest.java new file mode 100644 index 000000000..20dccd8f7 --- /dev/null +++ b/src/test/java/vendingmachine/InputValidatorTest.java @@ -0,0 +1,34 @@ +package vendingmachine; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import vendingmachine.validator.InputValidator; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +public class InputValidatorTest { + + private static final String ERROR_MESSAGE = "[ERROR]"; + + private final InputValidator inputValidator = new InputValidator(); + + @DisplayName("형식에 맞추어 입력하지 않을 경우 예외 발생") + @Test + void formatException() { + String input = "(콜라,1000,2)"; + + assertThatThrownBy(() -> inputValidator.validateFormat(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ERROR_MESSAGE); + } + + @DisplayName("숫자가 아닐 경우 예외 발생") + @Test + void numberException() { + String input = "o"; + + assertThatThrownBy(() -> inputValidator.validateIsNumber(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ERROR_MESSAGE); + } +} diff --git a/src/test/java/vendingmachine/domain/MoneyTest.java b/src/test/java/vendingmachine/domain/MoneyTest.java new file mode 100644 index 000000000..bf3ad7ecb --- /dev/null +++ b/src/test/java/vendingmachine/domain/MoneyTest.java @@ -0,0 +1,21 @@ +package vendingmachine.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class MoneyTest { + + private static final String ERROR_MESSAGE = "[ERROR]"; + + @Test + @DisplayName("남은 돈 보다 큰 돈을 내야할 경우 예외가 발생한다.") + void validateTest() { + Money money = new Money(1000); + + assertThatThrownBy(() -> money.pay(10000)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ERROR_MESSAGE); + } +} diff --git a/src/test/java/vendingmachine/domain/PriceTest.java b/src/test/java/vendingmachine/domain/PriceTest.java new file mode 100644 index 000000000..87646a170 --- /dev/null +++ b/src/test/java/vendingmachine/domain/PriceTest.java @@ -0,0 +1,29 @@ +package vendingmachine.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class PriceTest { + + private static final String ERROR_MESSAGE = "[ERROR]"; + + @Test + @DisplayName("10원 단위로 가격을 입력하지 않을 경우 예외가 발생한다.") + void validateUnit() { + // given + assertThatThrownBy(() -> new Price(11)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ERROR_MESSAGE); + } + + @Test + @DisplayName("가격이 100원 이하일 경우 예외가 발생한다.") + void validateMinPrice() { + // given + assertThatThrownBy(() -> new Price(30)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining(ERROR_MESSAGE); + } +}