diff --git a/README.md b/README.md index 5fa2560b46..c24d60859f 100644 --- a/README.md +++ b/README.md @@ -1 +1,66 @@ # java-lotto-precourse + +간단한 로또 발매기를 구현한다. + +- [ ] 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다. +- [ ] 당첨 번호 추첨 시 중복되지 않는 숫자 6개와 보너스 번호 1개를 뽑는다. +- [ ] 당첨은 1등부터 5등까지 있다. 당첨 기준과 금액은 아래와 같다. +``` +1등: 6개 번호 일치 / 2,000,000,000원 +2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원 +3등: 5개 번호 일치 / 1,500,000원 +4등: 4개 번호 일치 / 50,000원 +5등: 3개 번호 일치 / 5,000원 +``` +- [ ] 로또 구입 금액을 입력하면 구입 금액에 해당하는 만큼 로또를 발행해야 한다. +- [ ] 로또 1장의 가격은 1,000원이다. +- [ ] 당첨 번호와 보너스 번호를 입력받는다. +- [ ] 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역 및 수익률을 출력하고 로또 게임을 종료한다. +- 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException을 발생시키고, "[ERROR]"로 시작하는 에러 메시지를 출력 후 그 부분부터 입력을 다시 받는다. + - 1,000원으로 나누어 떨어지지 않는 경우 예외 처리한다. + - 로또 번호의 숫자 범위는 1~45까지이다. + - 당첨 번호의 개수는 6개이다. + - 로또 번호는 중복되지 않는다. +- Exception이 아닌 IllegalArgumentException, IllegalStateException 등과 같은 명확한 유형을 처리한다. +- 예외 상황 시 에러 문구를 출력해야 한다. 단, 에러 문구는 "[ERROR]"로 시작해야 한다. + - ex) [ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다. + +# View (입출력 요구 사항) + +## 입력 +로또 구입 금액을 입력 받는다. 구입 금액은 1,000원 단위로 입력 받는다. + +``14000`` + +당첨 번호를 입력 받는다. 번호는 쉼표(,)를 기준으로 구분한다. + +``1,2,3,4,5,6`` + +보너스 번호를 입력 받는다. + +``7`` + +## 출력 +발행한 로또 수량 및 번호를 출력한다. 로또 번호는 오름차순으로 정렬하여 보여준다. +``` +8개를 구매했습니다. +[8, 21, 23, 41, 42, 43] +[3, 5, 11, 16, 32, 38] +[7, 11, 16, 35, 36, 44] +[1, 8, 11, 31, 41, 42] +[13, 14, 16, 38, 42, 45] +[7, 11, 30, 40, 42, 43] +[2, 13, 22, 32, 38, 45] +[1, 3, 5, 14, 22, 45] +``` + +``` +당첨 내역을 출력한다. +3개 일치 (5,000원) - 1개 +4개 일치 (50,000원) - 0개 +5개 일치 (1,500,000원) - 0개 +5개 일치, 보너스 볼 일치 (30,000,000원) - 0개 +6개 일치 (2,000,000,000원) - 0개 +총 수익률은 62.5%입니다. +``` +수익률은 소수점 둘째 자리에서 반올림한다. (ex. 100.0%, 51.5%, 1,000,000.0%) \ No newline at end of file diff --git a/src/main/java/lotto/Application.java b/src/main/java/lotto/Application.java index d190922ba4..08061f5a6f 100644 --- a/src/main/java/lotto/Application.java +++ b/src/main/java/lotto/Application.java @@ -1,7 +1,82 @@ package lotto; +import lotto.reward.RewardCondition; +import lotto.view.InputView; +import lotto.view.OutputView; + +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + Money purchaseAmount = readPurchaseAmountWithRetry(); + User user = new User(purchaseAmount.getMoney()); + OutputView.printPurchasedLottos(user.getBoughtLotto()); + + Lotto winningLotto = readWinningLottoWithRetry(); + readBonusNumberWithRetry(winningLotto); + + Map result = calculateResults(user.getBoughtLotto(), winningLotto); + + long totalWinnings = 0; + for (Map.Entry entry : result.entrySet()) { + if (entry.getKey() != RewardCondition.MISS) { + totalWinnings += entry.getKey().getPrize() * entry.getValue(); + } + } + + Money userMoney = user.getMoney(); + userMoney.addMoney(new Money(totalWinnings)); + + double roi = userMoney.getRateOfReturn(); + + OutputView.printStatistics(result, roi); + } + + private static Money readPurchaseAmountWithRetry() { + while (true) { + try { + return new Money(InputView.readPurchaseAmount()); + } catch (IllegalArgumentException e) { + System.out.println("[ERROR] " + e.getMessage()); + } + } + } + + private static Lotto readWinningLottoWithRetry() { + while (true) { + try { + return new Lotto(InputView.readWinningNumbers()); + } catch (IllegalArgumentException e) { + System.out.println("[ERROR] " + e.getMessage()); + } + } + } + + private static void readBonusNumberWithRetry(Lotto winningLotto) { + while (true) { + try { + winningLotto.addLottoNumber(new LottoNumber(LottoNumberType.BONUS_NUMBER, InputView.readBonusNumber())); + return; + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + } + } + } + + private static Map calculateResults(List purchasedLottos, Lotto winningLotto) { + Map result = new EnumMap<>(RewardCondition.class); + for (RewardCondition condition : RewardCondition.values()) { + result.put(condition, 0); + } + + for (Lotto lotto : purchasedLottos) { + int matchCount = lotto.getSameOrdinaryLottoNumberCount(winningLotto.getNumbers()); + boolean hasBonus = lotto.getSameBonusLottoNumberCount(winningLotto.getNumbers()) >= 1; + RewardCondition reward = RewardCondition.valueOf(matchCount, hasBonus); + result.put(reward, result.get(reward) + 1); + } + return result; } -} +} \ No newline at end of file diff --git a/src/main/java/lotto/Lotto.java b/src/main/java/lotto/Lotto.java index 88fc5cf12b..57c491c982 100644 --- a/src/main/java/lotto/Lotto.java +++ b/src/main/java/lotto/Lotto.java @@ -1,20 +1,78 @@ package lotto; -import java.util.List; +import java.util.*; +import java.util.stream.Collectors; public class Lotto { - private final List numbers; + private final List numbers; public Lotto(List numbers) { - validate(numbers); - this.numbers = numbers; + List mapNumbers = new ArrayList<>(numbers.stream().map(LottoNumber::new).toList()); + validate(mapNumbers); + this.numbers = mapNumbers; } - private void validate(List numbers) { + private void validate(List numbers) { + validateNumbersSize(numbers); + validateDuplicationNumbers(numbers); + } + + private void validateNumbersSize(List numbers) { if (numbers.size() != 6) { throw new IllegalArgumentException("[ERROR] 로또 번호는 6개여야 합니다."); } } - // TODO: 추가 기능 구현 + private void validateDuplicationNumbers(List numbers) { + List combined = new ArrayList<>(numbers); + + if (this.numbers != null) { + combined.addAll(this.numbers); + } + + long distinctCount = combined.stream() + .map(LottoNumber::getNumber) + .distinct() + .count(); + + if (distinctCount != combined.size()) { + throw new IllegalArgumentException("[ERROR] 로또 번호는 중복될 수 없습니다."); + } + } + public List getNumbers() { + return this.numbers; + } + + public void addLottoNumber(LottoNumber lottoNumber) { + validateDuplicationNumbers(new ArrayList<>(List.of(lottoNumber))); + numbers.add(lottoNumber); + } + + public int getSameOrdinaryLottoNumberCount(List lottoNumbers) { + List numbers = this.numbers.stream() + .map(LottoNumber::getNumber) + .toList(); + + long sameCount = lottoNumbers.stream() + .map(LottoNumber::getNumber) + .filter(numbers::contains) + .count(); + + return (int) sameCount; + } + + public int getSameBonusLottoNumberCount(List lottoNumbers) { + List numbers = this.numbers.stream() + .filter(lottoNumber -> lottoNumber.getType() == LottoNumberType.BONUS_NUMBER) + .map(LottoNumber::getNumber) + .toList(); + + long sameCount = lottoNumbers.stream() + .filter(lottoNumber -> lottoNumber.getType() == LottoNumberType.BONUS_NUMBER) + .map(LottoNumber::getNumber) + .filter(numbers::contains) + .count(); + + return (int) sameCount; + } } diff --git a/src/main/java/lotto/LottoNumber.java b/src/main/java/lotto/LottoNumber.java new file mode 100644 index 0000000000..fd27756f59 --- /dev/null +++ b/src/main/java/lotto/LottoNumber.java @@ -0,0 +1,48 @@ +package lotto; + +import java.util.Objects; + +public class LottoNumber { + private final LottoNumberType type; + private final Integer number; + private final static int MIN_RANGE_NUMBER = 1; + private final static int MAX_RANGE_NUMBER = 45; + + public LottoNumber(Integer number) { + validateNumberRange(number); + this.type = LottoNumberType.ORDINARY_NUMBER; + this.number = number; + } + + public LottoNumber(LottoNumberType type, Integer number) { + validateNumberRange(number); + this.type = type; + this.number = number; + } + + private void validateNumberRange(int number) { + if (number < MIN_RANGE_NUMBER || number > MAX_RANGE_NUMBER) { + throw new IllegalArgumentException("[ERROR] 로또 번호는 1부터 45 사이의 숫자여야 합니다."); + } + } + + public Integer getNumber() { + return number; + } + + public LottoNumberType getType() { + return type; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (!(obj instanceof LottoNumber lottoNumber)) return false; + return Objects.equals(this.number, lottoNumber.number); + } + + @Override + public String toString() { + return this.number.toString(); + } +} diff --git a/src/main/java/lotto/LottoNumberType.java b/src/main/java/lotto/LottoNumberType.java new file mode 100644 index 0000000000..59ada1b707 --- /dev/null +++ b/src/main/java/lotto/LottoNumberType.java @@ -0,0 +1,6 @@ +package lotto; + +public enum LottoNumberType { + ORDINARY_NUMBER, + BONUS_NUMBER; +} diff --git a/src/main/java/lotto/Money.java b/src/main/java/lotto/Money.java new file mode 100644 index 0000000000..08f342a9c4 --- /dev/null +++ b/src/main/java/lotto/Money.java @@ -0,0 +1,38 @@ +package lotto; + +public class Money { + private final Long initMoney; + private Long currentMoney; + static long DIVIDE_STANDARD = 1_000L; + + public Money(long initMoney) { + validatePositiveInteger(initMoney); + if (initMoney % DIVIDE_STANDARD != 0) { + throw new IllegalArgumentException("[ERROR] 구입 금액은 1,000원 단위여야 합니다."); + } + this.initMoney = initMoney; + this.currentMoney = initMoney; + } + + private void validatePositiveInteger(long initMoney) { + if (initMoney <= 0) throw new IllegalArgumentException("[ERROR] 돈은 0이하가 될 수 없습니다."); + } + + public int getLottoBuyAvailableAmount() { + return (int) (initMoney / DIVIDE_STANDARD); + } + + public void addMoney(Money money) { + currentMoney += money.getMoney(); + } + + public Long getMoney() { + return currentMoney; + } + + public Double getRateOfReturn() { + if (currentMoney == 0) return 0.0; + double roi = (double) (currentMoney - initMoney) / initMoney * 100; + return Math.round(roi * 10.0) / 10.0; + } +} diff --git a/src/main/java/lotto/User.java b/src/main/java/lotto/User.java new file mode 100644 index 0000000000..f59ddfa84f --- /dev/null +++ b/src/main/java/lotto/User.java @@ -0,0 +1,31 @@ +package lotto; + +import camp.nextstep.edu.missionutils.Randoms; + +import java.util.ArrayList; +import java.util.List; + +public class User { + private final List boughtLotto = new ArrayList<>(); + private final Money money; + + public User(long initMoney) { + this.money = new Money(initMoney); + this.buyLottoReceipt(this.money.getLottoBuyAvailableAmount()); + } + + private void buyLottoReceipt(int amount) { + for (int i = 0; i < amount; i++) { + List randomNumbers = Randoms.pickUniqueNumbersInRange(1, 45, 6); + boughtLotto.add(new Lotto(randomNumbers)); + } + } + + public List getBoughtLotto() { + return this.boughtLotto; + } + + public Money getMoney() { + return money; + } +} diff --git a/src/main/java/lotto/reward/RewardCondition.java b/src/main/java/lotto/reward/RewardCondition.java new file mode 100644 index 0000000000..96e889b4e8 --- /dev/null +++ b/src/main/java/lotto/reward/RewardCondition.java @@ -0,0 +1,41 @@ +package lotto.reward; + +public enum RewardCondition { + FIRST(6, 2_000_000_000L), + SECOND(5, 30_000_000L), + THIRD(5, 1_500_000L), + FOURTH(4, 50_000L), + FIFTH(3, 5_000L), + MISS(0, 0L); + + private final int matchCount; + private final long prize; + + RewardCondition(int matchCount, long prize) { + this.matchCount = matchCount; + this.prize = prize; + } + + public static RewardCondition valueOf(int matchCount, boolean hasBonus) { + if (matchCount == 6) { + return FIRST; + } + if (matchCount == 5) { + if (hasBonus) { + return SECOND; + } + return THIRD; + } + if (matchCount == 4) { + return FOURTH; + } + if (matchCount == 3) { + return FIFTH; + } + return MISS; + } + + public long getPrize() { + return prize; + } +} \ No newline at end of file diff --git a/src/main/java/lotto/view/InputView.java b/src/main/java/lotto/view/InputView.java new file mode 100644 index 0000000000..b94b62e45c --- /dev/null +++ b/src/main/java/lotto/view/InputView.java @@ -0,0 +1,28 @@ +package lotto.view; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +import static camp.nextstep.edu.missionutils.Console.readLine; + +public class InputView { + public static int readPurchaseAmount() { + System.out.println("구입금액을 입력해 주세요."); + return Integer.parseInt(readLine()); + } + + public static List readWinningNumbers() { + System.out.println("당첨 번호를 입력해 주세요."); + String[] numbers = readLine().split(","); + return Arrays.stream(numbers) + .map(String::trim) + .map(Integer::parseInt) + .collect(Collectors.toList()); + } + + public static int readBonusNumber() { + System.out.println("보너스 번호를 입력해 주세요."); + return Integer.parseInt(readLine()); + } +} diff --git a/src/main/java/lotto/view/OutputView.java b/src/main/java/lotto/view/OutputView.java new file mode 100644 index 0000000000..7a257f3688 --- /dev/null +++ b/src/main/java/lotto/view/OutputView.java @@ -0,0 +1,32 @@ +package lotto.view; + +import lotto.Lotto; +import lotto.LottoNumber; +import lotto.reward.RewardCondition; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +public class OutputView { + + public static void printPurchasedLottos(List lottos) { + System.out.println(lottos.size() + "개를 구매했습니다."); + for (Lotto lotto : lottos) { + lotto.getNumbers().sort(Comparator.comparingInt(LottoNumber::getNumber)); + System.out.println(lotto.getNumbers()); + } + System.out.println(); + } + + public static void printStatistics(Map result, double profitRate) { + System.out.println("당첨 통계"); + System.out.println("---"); + System.out.printf("3개 일치 (5,000원) - %d개%n", result.getOrDefault(RewardCondition.FIFTH, 0)); + System.out.printf("4개 일치 (50,000원) - %d개%n", result.getOrDefault(RewardCondition.FOURTH, 0)); + System.out.printf("5개 일치 (1,500,000원) - %d개%n", result.getOrDefault(RewardCondition.THIRD, 0)); + System.out.printf("5개 일치, 보너스 볼 일치 (30,000,000원) - %d개%n", result.getOrDefault(RewardCondition.SECOND, 0)); + System.out.printf("6개 일치 (2,000,000,000원) - %d개%n", result.getOrDefault(RewardCondition.FIRST, 0)); + System.out.printf("총 수익률은 %.1f%%입니다.%n", profitRate); + } +} diff --git a/src/test/java/lotto/LottoNumberTest.java b/src/test/java/lotto/LottoNumberTest.java new file mode 100644 index 0000000000..db7b18cef2 --- /dev/null +++ b/src/test/java/lotto/LottoNumberTest.java @@ -0,0 +1,28 @@ +package lotto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LottoNumberTest { + + @DisplayName("숫자가 같으면 동일하다") + @Test + void equal_test() { + LottoNumber number1 = new LottoNumber(LottoNumberType.ORDINARY_NUMBER, 1); + LottoNumber number2 = new LottoNumber(LottoNumberType.ORDINARY_NUMBER, 1); + LottoNumber number3 = new LottoNumber(LottoNumberType.BONUS_NUMBER, 1); + assertEquals(number1, number2); + assertEquals(number1, number3); + } + + @DisplayName("숫자는 1~45까지 숫자를 가질 수 있다.") + @Test + void validateNumberRange() { + assertThatThrownBy(() -> new LottoNumber(99)) + .isInstanceOf(IllegalArgumentException.class); + } + +} \ No newline at end of file diff --git a/src/test/java/lotto/LottoTest.java b/src/test/java/lotto/LottoTest.java index 309f4e50ae..88e3c96148 100644 --- a/src/test/java/lotto/LottoTest.java +++ b/src/test/java/lotto/LottoTest.java @@ -6,6 +6,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; class LottoTest { @Test @@ -21,5 +22,24 @@ class LottoTest { .isInstanceOf(IllegalArgumentException.class); } - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 + + @DisplayName("겹치는 숫자를 구할 수 있다.") + @Test + void getSameOrdinaryNumberCount() { + Lotto lotto1 = new Lotto(List.of(1, 2, 3, 4, 5, 6)); + Lotto lotto2 = new Lotto(List.of(1, 2, 3, 9, 4, 5)); + + assertEquals(5, lotto1.getSameOrdinaryLottoNumberCount(lotto2.getNumbers())); + } + + @DisplayName("겹치는 보너스 숫자를 구할 수 있다.") + @Test + void getSameBonusNumberCount() { + Lotto lotto1 = new Lotto(List.of(1, 2, 3, 4, 5, 6)); + Lotto lotto2 = new Lotto(List.of(1, 2, 3, 9, 4, 5)); + lotto1.addLottoNumber(new LottoNumber(LottoNumberType.BONUS_NUMBER, 10)); + lotto2.addLottoNumber(new LottoNumber(LottoNumberType.BONUS_NUMBER, 10)); + + assertEquals(1, lotto1.getSameBonusLottoNumberCount(lotto2.getNumbers())); + } } diff --git a/src/test/java/lotto/MoneyTest.java b/src/test/java/lotto/MoneyTest.java new file mode 100644 index 0000000000..f1bf5d5a99 --- /dev/null +++ b/src/test/java/lotto/MoneyTest.java @@ -0,0 +1,36 @@ +package lotto; + +import org.junit.jupiter.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 static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; + +class MoneyTest { + @ParameterizedTest + @DisplayName("돈은 0이하가 될 수 없다.") + @ValueSource(ints = {0,-1,-2}) + void money_must_positive_integer(int money) { + assertThatThrownBy(() -> new Money(money)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("돈은 천원으로 나누어 떨어진다.") + void 돈은_천원으로_나누어_떨어진다() { + assertThatThrownBy(() -> new Money(1540)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + @DisplayName("수익률을 계산한다.") + void calculate_rate_of_return() { + Money money = new Money(8_000); + money.addMoney(new Money(5_000)); + assertEquals(62.5, money.getRateOfReturn()); + } + +} \ No newline at end of file diff --git a/src/test/java/lotto/UserTest.java b/src/test/java/lotto/UserTest.java new file mode 100644 index 0000000000..5c6cf06b5c --- /dev/null +++ b/src/test/java/lotto/UserTest.java @@ -0,0 +1,20 @@ +package lotto; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +class UserTest { + + @DisplayName("구매할 돈 만큼 로또를 구매한다.") + @ParameterizedTest + @ValueSource(ints = {1_000, 18_000, 3_000}) + void buy_lotto_available(int initMoney) { + User user = new User(initMoney); + + assertEquals(initMoney / Money.DIVIDE_STANDARD, user.getBoughtLotto().size()); + } + +} \ No newline at end of file