diff --git a/README.md b/README.md index 5fa2560b46..0a591d4577 100644 --- a/README.md +++ b/README.md @@ -1 +1,60 @@ # java-lotto-precourse + +## 기능 목록 +### 1. 입력 처리 + 1.1 구매 금액 입력 + - 양의 정수, 1000원 단위, 1000원으로 나누어 떨어져야 함 + - < 예외 > 숫자가 아님, 음수 또는 0, 1000원 단위 아님 + 1.2 당첨 번호 입력 + - ","로 토큰 분리, 6개 숫자, 중복 없음, 1~45 범위 + - < 예외 > 개수 불일치, 공백, 빈 토큰, 중복, 범위 위배 + 1.3 보너스 번호 입력 + - 1개 숫자, 당첨 번호와 중북 금지, 1~45 범위 + - < 예외 > 당첨 번호와 중복, 범위 위배 + -> 예외 시 [ERROR] 후 재입력 + +### 2. 로또 발행 + 2.1 구매 개수 만큼 로또 발행 + - 입력 금액 / 1000 + - 각 로또는 1~45 범위에서 중복 없이 6개 숫자를 생성 + - 주어진 메서드 사용 (Randoms.pickUniqueNumbersInRange) + +### 3. 출력 + 3.1 발행 결과 + - "n개를 구매했습니다." + - 다음 줄부터 각 로또 번호를 [1, 2, 3, 4, 5, 6] 포맷으로 출력 + - 로또 번호는 오름차순으로 출력 + 3.2 당첨 통계 + + 당첨 통계 + --- + 3개 일치 (5,000원) - 0개 + 4개 일치 (50,000원) - 0개 + 5개 일치 (1,500,000원) - 0개 + 5개 일치, 보너스 볼 일치 (30,000,000원) - 0개 + 6개 일치 (2,000,000,000원) - 0개 + + 위 포맷으로 출력 + 3.3 수익률 + - "총 수익률은 00.0%입니다." + - 소수 둘째 자리 반올림 + +### 4. 당첨 확인 및 정산 + 4.1 당첨 확인 + - 각 로또와 당첨 번호를 비교하여 일치 개수 및 보너스 일치 여부 계산 + 4.2 등수 매핑 + + 1등: 6개 번호 일치 / 2,000,000,000원 + 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원 + 3등: 5개 번호 일치 / 1,500,000원 + 4등: 4개 번호 일치 / 50,000원 + 5등: 3개 번호 일치 / 5,000원 + + 4.3 정산 + - 총 상금 합계 계산 + - 수익률 계산 (총 상금 / 총 구매액 * 100) + +### 5. 예외 처리 + 5.1 사용자가 잘못된 값을 입력할 경우 IllegalArgumentException 발생 + 5.2 "[ERROR]"로 시작하는 에러 메시지 출력 후 그 부분부터 다시 입력 받음 + 5.3 Exception 포괄 처리를 지양하고 명확한 유형으로 처리 diff --git a/src/main/java/lotto/Application.java b/src/main/java/lotto/Application.java index d190922ba4..357919c04e 100644 --- a/src/main/java/lotto/Application.java +++ b/src/main/java/lotto/Application.java @@ -1,7 +1,9 @@ package lotto; +import lotto.controller.LottoController; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + new LottoController().run(); } } diff --git a/src/main/java/lotto/Lotto.java b/src/main/java/lotto/Lotto.java deleted file mode 100644 index 88fc5cf12b..0000000000 --- a/src/main/java/lotto/Lotto.java +++ /dev/null @@ -1,20 +0,0 @@ -package lotto; - -import java.util.List; - -public class Lotto { - private final List numbers; - - public Lotto(List numbers) { - validate(numbers); - this.numbers = numbers; - } - - private void validate(List numbers) { - if (numbers.size() != 6) { - throw new IllegalArgumentException("[ERROR] 로또 번호는 6개여야 합니다."); - } - } - - // TODO: 추가 기능 구현 -} diff --git a/src/main/java/lotto/controller/LottoController.java b/src/main/java/lotto/controller/LottoController.java new file mode 100644 index 0000000000..f0efb7b3c8 --- /dev/null +++ b/src/main/java/lotto/controller/LottoController.java @@ -0,0 +1,85 @@ +package lotto.controller; + +import lotto.domain.Lotto; +import lotto.domain.WinningNumbers; +import lotto.domain.WinningStatistics; + +import lotto.random.LottoNumberGenerator; +import lotto.service.LottoGenerator; +import lotto.service.ResultCalculator; +import lotto.support.ErrorMessages; +import lotto.view.InputView; +import lotto.view.OutputView; + +import java.util.List; + +import static lotto.support.Constants.PRICE_PER_TICKET; + +public final class LottoController { + + private final InputView inputView; + private final OutputView outputView; + + private final LottoGenerator generator; + + public LottoController() { + this.inputView = new InputView(); + this.outputView = new OutputView(); + + LottoNumberGenerator picker = new LottoNumberGenerator(); + this.generator = new LottoGenerator(picker); + } + + public void run() { + try { + long purchaseAmount = askPurchaseAmount(); + List tickets = purchase(purchaseAmount); + outputView.printPurchased(tickets); + + WinningNumbers winningNumbers = askWinningNumbers(); + WinningStatistics stats = ResultCalculator.calculate(tickets, winningNumbers, purchaseAmount); + + outputView.printStatistics(stats); + } finally { + inputView.close(); + } + } + + private long askPurchaseAmount() { + while (true) { + try { + long money = inputView.readPurchaseAmount(); + validatePurchaseUnit(money); + return money; + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + } + + private void validatePurchaseUnit(long money) { + if (money <= 0) { + throw new IllegalArgumentException(ErrorMessages.INVALID_MONEY_RANGE.message()); + } + if (money % PRICE_PER_TICKET != 0) { + throw new IllegalArgumentException(ErrorMessages.INVALID_MONEY_UNIT.message()); + } + } + + private List purchase(long money) { + int count = (int) (money / PRICE_PER_TICKET); + return generator.generateLottoes(count); + } + + private WinningNumbers askWinningNumbers() { + while (true) { + try { + List mains = inputView.readWinningNumbers(); + int bonus = inputView.readBonusNumber(); + return new WinningNumbers(mains, bonus); + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/lotto/domain/Lotto.java b/src/main/java/lotto/domain/Lotto.java new file mode 100644 index 0000000000..515c547c4c --- /dev/null +++ b/src/main/java/lotto/domain/Lotto.java @@ -0,0 +1,28 @@ +package lotto.domain; + +import lotto.validator.LottoValidator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class Lotto { + private final List numbers; + + public Lotto(List numbers) { + validate(numbers); + List copy = new ArrayList<>(numbers); + Collections.sort(copy); + this.numbers = Collections.unmodifiableList(copy); + } + + private void validate(List numbers) { + LottoValidator.validateSize(numbers); + LottoValidator.validateRange(numbers); + LottoValidator.validateDuplicate(numbers); + } + + public List getNumbers() { + return numbers; + } +} diff --git a/src/main/java/lotto/domain/MatchResult.java b/src/main/java/lotto/domain/MatchResult.java new file mode 100644 index 0000000000..ff69cb4098 --- /dev/null +++ b/src/main/java/lotto/domain/MatchResult.java @@ -0,0 +1,23 @@ +package lotto.domain; + +public final class MatchResult { + private final int matchedCount; + private final boolean bonusMatched; + + public MatchResult(int matchedCount, boolean bonusMatched) { + this.matchedCount = matchedCount; + this.bonusMatched = bonusMatched; + } + + public int getMatchedCount() { + return matchedCount; + } + + public boolean isBonusMatched() { + return bonusMatched; + } + + public Rank getRank() { + return Rank.findRankByMatchCountAndBonus(matchedCount, bonusMatched); + } +} diff --git a/src/main/java/lotto/domain/Rank.java b/src/main/java/lotto/domain/Rank.java new file mode 100644 index 0000000000..8d86d7545c --- /dev/null +++ b/src/main/java/lotto/domain/Rank.java @@ -0,0 +1,35 @@ +package lotto.domain; + +import static lotto.support.Constants.*; + +public enum Rank { + FIRST(6, false, PRIZE_FIRST), + SECOND(5, true, PRIZE_SECOND), + THIRD(5, false, PRIZE_THIRD), + FOURTH(4, false, PRIZE_FOURTH), + FIFTH(3, false, PRIZE_FIFTH), + OOPS(0, false, PRIZE_NONE); + + private final int matchCount; + private final boolean requireBonus; + private final long prize; + + Rank(int matchCount, boolean requireBonus, long prize) { + this.matchCount = matchCount; + this.requireBonus = requireBonus; + this.prize = prize; + } + + public long getPrize() { + return prize; + } + + public static Rank findRankByMatchCountAndBonus(int matched, boolean bonusMatched) { + if (matched == 6) return FIRST; + if (matched == 5 && bonusMatched) return SECOND; + if (matched == 5) return THIRD; + if (matched == 4) return FOURTH; + if (matched == 3) return FIFTH; + return OOPS; + } +} diff --git a/src/main/java/lotto/domain/WinningNumbers.java b/src/main/java/lotto/domain/WinningNumbers.java new file mode 100644 index 0000000000..d287f97e59 --- /dev/null +++ b/src/main/java/lotto/domain/WinningNumbers.java @@ -0,0 +1,45 @@ +package lotto.domain; + +import lotto.validator.BonusValidator; +import lotto.validator.LottoValidator; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class WinningNumbers { + private final List numbers; + private final int bonusNumber; + + public WinningNumbers(List numbers, int bonusNumber) { + LottoValidator.validateSize(numbers); + LottoValidator.validateRange(numbers); + LottoValidator.validateDuplicate(numbers); + BonusValidator.validateBonus(numbers, bonusNumber); + + List copy = new ArrayList<>(numbers); + Collections.sort(copy); + this.numbers = Collections.unmodifiableList(copy); + this.bonusNumber = bonusNumber; + } + + public MatchResult match(Lotto lotto) { + int matchedCount = 0; + boolean bonusMatched = false; + for (int n : lotto.getNumbers()) { + if (numbers.contains(n)) { + matchedCount++; + } + } + bonusMatched = lotto.getNumbers().contains(bonusNumber); + return new MatchResult(matchedCount, bonusMatched); + } + + public List getNumbers() { + return numbers; + } + + public int getBonusNumber() { + return bonusNumber; + } +} diff --git a/src/main/java/lotto/domain/WinningStatistics.java b/src/main/java/lotto/domain/WinningStatistics.java new file mode 100644 index 0000000000..80d48c8a61 --- /dev/null +++ b/src/main/java/lotto/domain/WinningStatistics.java @@ -0,0 +1,47 @@ +package lotto.domain; + +import java.math.BigDecimal; +import java.text.DecimalFormat; +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; + +public final class WinningStatistics { + + private final EnumMap counts; + private final long totalPrize; + private final BigDecimal roiPercent; + + public WinningStatistics(Map counts, long totalPrize, BigDecimal roiPercent) { + this.counts = new EnumMap<>(Rank.class); + for (Rank rank : Rank.values()) { + int count = counts.getOrDefault(rank, 0); + this.counts.put(rank, count); + } + this.totalPrize = totalPrize; + this.roiPercent = roiPercent; + } + + public int getCount(Rank rank) { + Integer count = counts.get(rank); + if (count == null) return 0; + return count; + } + + public Map getCounts() { + return Collections.unmodifiableMap(counts); + } + + public long getTotalPrize() { + return totalPrize; + } + + public BigDecimal getRoiPercent() { + return roiPercent; + } + + public String formatRoiPercent() { + DecimalFormat df = new DecimalFormat("#,##0.0'%'"); + return df.format(roiPercent); + } +} \ No newline at end of file diff --git a/src/main/java/lotto/random/LottoNumberGenerator.java b/src/main/java/lotto/random/LottoNumberGenerator.java new file mode 100644 index 0000000000..d0c9fef994 --- /dev/null +++ b/src/main/java/lotto/random/LottoNumberGenerator.java @@ -0,0 +1,13 @@ +package lotto.random; + +import camp.nextstep.edu.missionutils.Randoms; + +import java.util.List; + +import static lotto.support.Constants.*; + +public final class LottoNumberGenerator { + public List generateUniqueLottoNumbers() { + return Randoms.pickUniqueNumbersInRange(MIN_NUMBER, MAX_NUMBER, LOTTO_SIZE); + } +} diff --git a/src/main/java/lotto/service/LottoGenerator.java b/src/main/java/lotto/service/LottoGenerator.java new file mode 100644 index 0000000000..353cffafc8 --- /dev/null +++ b/src/main/java/lotto/service/LottoGenerator.java @@ -0,0 +1,32 @@ +package lotto.service; + +import lotto.domain.Lotto; +import lotto.random.LottoNumberGenerator; +import lotto.support.ErrorMessages; + +import java.util.ArrayList; +import java.util.List; + +public final class LottoGenerator { + + private final LottoNumberGenerator generator; + + public LottoGenerator(LottoNumberGenerator generator) { + this.generator = generator; + } + + public Lotto generateLotto() { + return new Lotto(generator.generateUniqueLottoNumbers()); + } + + public List generateLottoes(int count) { + if (count <= 0) { + throw new IllegalArgumentException(ErrorMessages.INVALID_LOTTO_AMOUNT.message()); + } + List tickets = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + tickets.add(generateLotto()); + } + return tickets; + } +} \ No newline at end of file diff --git a/src/main/java/lotto/service/Purchaser.java b/src/main/java/lotto/service/Purchaser.java new file mode 100644 index 0000000000..f86d4cf2b3 --- /dev/null +++ b/src/main/java/lotto/service/Purchaser.java @@ -0,0 +1,32 @@ +package lotto.service; + +import lotto.domain.Lotto; +import lotto.support.ErrorMessages; + +import java.util.List; + +import static lotto.support.Constants.PRICE_PER_TICKET; + +public final class Purchaser { + + private final LottoGenerator generator; + + public Purchaser(LottoGenerator generator) { + this.generator = generator; + } + + public int getTicketCount(long money) { + if (money <= 0) { + throw new IllegalArgumentException(ErrorMessages.INVALID_MONEY_RANGE.message()); + } + if (money % PRICE_PER_TICKET != 0) { + throw new IllegalArgumentException(ErrorMessages.INVALID_MONEY_UNIT.message()); + } + return (int) (money / PRICE_PER_TICKET); + } + + public List purchase(long money) { + int count = getTicketCount(money); + return generator.generateLottoes(count); + } +} \ No newline at end of file diff --git a/src/main/java/lotto/service/ResultCalculator.java b/src/main/java/lotto/service/ResultCalculator.java new file mode 100644 index 0000000000..ac1c4a870d --- /dev/null +++ b/src/main/java/lotto/service/ResultCalculator.java @@ -0,0 +1,60 @@ +package lotto.service; + +import lotto.domain.*; +import lotto.support.ErrorMessages; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +public final class ResultCalculator { + + private ResultCalculator() { } + + public static WinningStatistics calculate(List tickets, WinningNumbers winningNumbers, long purchaseAmount) { + Map counts = initCounts(); + for (Lotto ticket : tickets) { + MatchResult result = winningNumbers.match(ticket); + Rank rank = result.getRank(); + counts.put(rank, counts.get(rank) + 1); + } + + long totalPrize = sumPrize(counts); + BigDecimal yieldPercent = computeRoiPercent(totalPrize, purchaseAmount); + return new WinningStatistics(counts, totalPrize, yieldPercent); + } + + private static Map initCounts() { + EnumMap counts = new EnumMap<>(Rank.class); + counts.put(Rank.FIRST, 0); + counts.put(Rank.SECOND, 0); + counts.put(Rank.THIRD, 0); + counts.put(Rank.FOURTH, 0); + counts.put(Rank.FIFTH, 0); + counts.put(Rank.OOPS, 0); + return counts; + } + + private static long sumPrize(Map counts) { + long sum = 0L; + sum += (long) counts.get(Rank.FIRST) * Rank.FIRST.getPrize(); + sum += (long) counts.get(Rank.SECOND) * Rank.SECOND.getPrize(); + sum += (long) counts.get(Rank.THIRD) * Rank.THIRD.getPrize(); + sum += (long) counts.get(Rank.FOURTH) * Rank.FOURTH.getPrize(); + sum += (long) counts.get(Rank.FIFTH) * Rank.FIFTH.getPrize(); + return sum; + } + + private static BigDecimal computeRoiPercent(long totalPrize, long purchaseAmount) { + if (purchaseAmount <= 0) { + throw new IllegalArgumentException(ErrorMessages.INVALID_MONEY_RANGE.message()); + } + BigDecimal prize = BigDecimal.valueOf(totalPrize); + BigDecimal paid = BigDecimal.valueOf(purchaseAmount); + BigDecimal roi = prize.divide(paid, 6, RoundingMode.HALF_UP); + BigDecimal roiPercent = roi.multiply(BigDecimal.valueOf(100)); + return roiPercent.setScale(1, RoundingMode.HALF_UP); + } +} \ No newline at end of file diff --git a/src/main/java/lotto/support/Constants.java b/src/main/java/lotto/support/Constants.java new file mode 100644 index 0000000000..558bfa92af --- /dev/null +++ b/src/main/java/lotto/support/Constants.java @@ -0,0 +1,17 @@ +package lotto.support; + +public class Constants { + private Constants() {} + + public static final int PRICE_PER_TICKET = 1_000; + public static final int LOTTO_SIZE = 6; + public static final int MIN_NUMBER = 1; + public static final int MAX_NUMBER = 45; + + public static final long PRIZE_FIRST = 2_000_000_000L; + public static final long PRIZE_SECOND = 30_000_000L; + public static final long PRIZE_THIRD = 1_500_000L; + public static final long PRIZE_FOURTH = 50_000L; + public static final long PRIZE_FIFTH = 5_000L; + public static final long PRIZE_NONE = 0L; +} diff --git a/src/main/java/lotto/support/ErrorMessages.java b/src/main/java/lotto/support/ErrorMessages.java new file mode 100644 index 0000000000..fb50cb522d --- /dev/null +++ b/src/main/java/lotto/support/ErrorMessages.java @@ -0,0 +1,23 @@ +package lotto.support; + +public enum ErrorMessages { + INVALID_MONEY_RANGE("구입 금액은 양의 정수여야 합니다."), + INVALID_MONEY_UNIT("구입 금액은 1,000원 단위여야 합니다."), + INVALID_LOTTO_SIZE("로또 번호는 6개여야 합니다."), + INVALID_NUMBER_RANGE("로또 번호는 1부터 45 사이의 숫자여야 합니다."), + DUPLICATE_NUMBER("로또 번호는 중복될 수 없습니다."), + DUPLICATE_BONUS("보너스 번호는 당첨 번호와 중복될 수 없습니다."), + INVALID_INPUT("입력 형식이 올바르지 않습니다."), + INVALID_LOTTO_AMOUNT("발행 수량은 1장 이상이어야 합니다."); + + public static final String PREFIX = "[ERROR] "; + private final String message; + + ErrorMessages(String message) { + this.message = message; + } + + public String message() { + return PREFIX + message; + } +} diff --git a/src/main/java/lotto/support/Messages.java b/src/main/java/lotto/support/Messages.java new file mode 100644 index 0000000000..a06eb213c2 --- /dev/null +++ b/src/main/java/lotto/support/Messages.java @@ -0,0 +1,15 @@ +package lotto.support; + +public class Messages { + public static final String HEADING_STATISTICS = "당첨 통계"; + public static final String HEADING_SEPARATOR = "---"; + + public static final String RESULT_3_MATCH = "3개 일치 (5,000원) - %d개"; + public static final String RESULT_4_MATCH = "4개 일치 (50,000원) - %d개"; + public static final String RESULT_5_MATCH = "5개 일치 (1,500,000원) - %d개"; + public static final String RESULT_5_BONUS_MATCH = "5개 일치, 보너스 볼 일치 (30,000,000원) - %d개"; + public static final String RESULT_6_MATCH = "6개 일치 (2,000,000,000원) - %d개"; + + public static final String MESSAGE_PURCHASE_COUNT = "%d개를 구매했습니다."; + public static final String MESSAGE_ROI = "총 수익률은 %s입니다."; +} diff --git a/src/main/java/lotto/validator/BonusValidator.java b/src/main/java/lotto/validator/BonusValidator.java new file mode 100644 index 0000000000..5c7024dfaf --- /dev/null +++ b/src/main/java/lotto/validator/BonusValidator.java @@ -0,0 +1,27 @@ +package lotto.validator; + +import lotto.support.ErrorMessages; + +import java.util.List; + +import static lotto.support.Constants.MAX_NUMBER; +import static lotto.support.Constants.MIN_NUMBER; + +public class BonusValidator { + public static void validateBonus(List numbers, int bonus) { + validateBonusRange(bonus); + validateBonusDuplicate(numbers, bonus); + } + + public static void validateBonusRange(int bonus) { + if (bonus < MIN_NUMBER || bonus > MAX_NUMBER) { + throw new IllegalArgumentException(ErrorMessages.INVALID_NUMBER_RANGE.message()); + } + } + + public static void validateBonusDuplicate(List numbers, int bonus) { + if (numbers.contains(bonus)) { + throw new IllegalArgumentException(ErrorMessages.DUPLICATE_BONUS.message()); + } + } +} diff --git a/src/main/java/lotto/validator/LottoValidator.java b/src/main/java/lotto/validator/LottoValidator.java new file mode 100644 index 0000000000..22043ac070 --- /dev/null +++ b/src/main/java/lotto/validator/LottoValidator.java @@ -0,0 +1,30 @@ +package lotto.validator; + +import lotto.support.ErrorMessages; + +import java.util.HashSet; +import java.util.List; + +import static lotto.support.Constants.*; + +public class LottoValidator { + public static void validateSize(List numbers) { + if (numbers == null || numbers.size() != LOTTO_SIZE) { + throw new IllegalArgumentException(ErrorMessages.INVALID_LOTTO_SIZE.message()); + } + } + + public static void validateRange(List numbers) { + for (Integer n : numbers) { + if (n == null || n < MIN_NUMBER || n > MAX_NUMBER) { + throw new IllegalArgumentException(ErrorMessages.INVALID_NUMBER_RANGE.message()); + } + } + } + + public static void validateDuplicate(List numbers) { + if (new HashSet<>(numbers).size() != numbers.size()) { + throw new IllegalArgumentException(ErrorMessages.DUPLICATE_NUMBER.message()); + } + } +} diff --git a/src/main/java/lotto/view/InputView.java b/src/main/java/lotto/view/InputView.java new file mode 100644 index 0000000000..3b3a463517 --- /dev/null +++ b/src/main/java/lotto/view/InputView.java @@ -0,0 +1,70 @@ +package lotto.view; + +import camp.nextstep.edu.missionutils.Console; +import lotto.support.ErrorMessages; + +import java.util.ArrayList; +import java.util.List; + +public final class InputView { + + private static final String INPUT_MONEY = "구입금액을 입력해 주세요."; + private static final String INPUT_WINNING = "당첨 번호를 입력해 주세요."; + private static final String INPUT_BONUS = "보너스 번호를 입력해 주세요."; + + public long readPurchaseAmount() { + System.out.println(INPUT_MONEY); + String line = Console.readLine(); + return parseLong(line); + } + + public List readWinningNumbers() { + System.out.println(); + System.out.println(INPUT_WINNING); + String line = Console.readLine(); + return parseCommaSeparatedInts(line); + } + + public int readBonusNumber() { + System.out.println(); + System.out.println(INPUT_BONUS); + String line = Console.readLine(); + return parseInt(line); + } + + public void close() { + Console.close(); + } + + private long parseLong(String text) { + try { + return Long.parseLong(text.trim()); + } catch (Exception e) { + throw new IllegalArgumentException(ErrorMessages.INVALID_INPUT.message()); + } + } + + private int parseInt(String text) { + try { + return Integer.parseInt(text.trim()); + } catch (Exception e) { + throw new IllegalArgumentException(ErrorMessages.INVALID_INPUT.message()); + } + } + + private List parseCommaSeparatedInts(String text) { + if (text == null || text.trim().isEmpty()) { + throw new IllegalArgumentException(ErrorMessages.INVALID_INPUT.message()); + } + String[] tokens = text.split(","); + List numbers = new ArrayList<>(tokens.length); + try { + for (String token : tokens) { + numbers.add(Integer.parseInt(token.trim())); + } + } catch (Exception e) { + throw new IllegalArgumentException(ErrorMessages.INVALID_INPUT.message()); + } + return numbers; + } +} \ No newline at end of file diff --git a/src/main/java/lotto/view/OutputView.java b/src/main/java/lotto/view/OutputView.java new file mode 100644 index 0000000000..4a733b07a8 --- /dev/null +++ b/src/main/java/lotto/view/OutputView.java @@ -0,0 +1,45 @@ +package lotto.view; + +import lotto.domain.Lotto; +import lotto.domain.Rank; +import lotto.domain.WinningStatistics; +import lotto.support.ErrorMessages; +import lotto.support.Messages; + +import java.util.List; + +public final class OutputView { + + public void printPurchased(List tickets) { + System.out.printf((Messages.MESSAGE_PURCHASE_COUNT) + "%n", tickets.size()); + for (Lotto lotto : tickets) { + System.out.println(lotto.getNumbers()); + } + } + + public void printStatistics(WinningStatistics stats) { + System.out.println(); + System.out.println(Messages.HEADING_STATISTICS); + System.out.println(Messages.HEADING_SEPARATOR); + + System.out.printf((Messages.RESULT_3_MATCH) + "%n", stats.getCount(Rank.FIFTH)); + System.out.printf((Messages.RESULT_4_MATCH) + "%n", stats.getCount(Rank.FOURTH)); + System.out.printf((Messages.RESULT_5_MATCH) + "%n", stats.getCount(Rank.THIRD)); + System.out.printf((Messages.RESULT_5_BONUS_MATCH) + "%n", stats.getCount(Rank.SECOND)); + System.out.printf((Messages.RESULT_6_MATCH) + "%n", stats.getCount(Rank.FIRST)); + + System.out.printf((Messages.MESSAGE_ROI) + "%n", stats.formatRoiPercent()); + } + + public void printError(String message) { + if (message == null || message.isBlank()) { + System.out.println(ErrorMessages.PREFIX + ErrorMessages.INVALID_INPUT.message()); + return; + } + if (message.startsWith(ErrorMessages.PREFIX)) { + System.out.println(message); + return; + } + System.out.println(ErrorMessages.PREFIX + message); + } +} \ No newline at end of file diff --git a/src/test/java/lotto/LottoTest.java b/src/test/java/lotto/LottoTest.java deleted file mode 100644 index 309f4e50ae..0000000000 --- a/src/test/java/lotto/LottoTest.java +++ /dev/null @@ -1,25 +0,0 @@ -package lotto; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class LottoTest { - @Test - void 로또_번호의_개수가_6개가_넘어가면_예외가_발생한다() { - assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 6, 7))) - .isInstanceOf(IllegalArgumentException.class); - } - - @DisplayName("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.") - @Test - void 로또_번호에_중복된_숫자가_있으면_예외가_발생한다() { - assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 5))) - .isInstanceOf(IllegalArgumentException.class); - } - - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 -} diff --git a/src/test/java/lotto/domain/LottoTest.java b/src/test/java/lotto/domain/LottoTest.java new file mode 100644 index 0000000000..a32d667d9f --- /dev/null +++ b/src/test/java/lotto/domain/LottoTest.java @@ -0,0 +1,51 @@ +package lotto.domain; + +import lotto.support.ErrorMessages; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +// Lotto 및 LottoValidator 단위 테스트 +class LottoTest { + @DisplayName("번호가_6개가_아니면_예외") + @Test + void 번호가_6개가_아니면_예외() { + assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + + assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 6, 7))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @DisplayName("번호가_범위를_벗어나면_예외") + @Test + void 번호가_범위를_벗어나면_예외() { + assertThatThrownBy(() -> new Lotto(List.of(0, 2, 3, 4, 5, 6))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + + assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 46))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @DisplayName("번호에_중복이_있으면_예외") + @Test + void 번호에_중복이_있으면_예외() { + assertThatThrownBy(() -> new Lotto(List.of(1, 1, 3, 4, 5, 6))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @DisplayName("오름차순_정렬되어_보관") + @Test + void 오름차순_정렬되어_보관() { + Lotto lotto = new Lotto(List.of(6, 1, 3, 5, 2 ,4)); + assertThat(lotto.getNumbers()).containsExactly(1, 2, 3, 4, 5, 6); + } +} diff --git a/src/test/java/lotto/domain/MatchResultTest.java b/src/test/java/lotto/domain/MatchResultTest.java new file mode 100644 index 0000000000..2baf79b310 --- /dev/null +++ b/src/test/java/lotto/domain/MatchResultTest.java @@ -0,0 +1,46 @@ +package lotto.domain; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class MatchResultTest { + @ParameterizedTest + @MethodSource("mappingCases") + void getRank_매핑(int matched, boolean bonus, Rank expected) { + MatchResult matchResult = new MatchResult(matched, bonus); + assertThat(matchResult.getRank()).isEqualTo(expected); + } + + static Stream mappingCases() { + return Stream.of( + Arguments.of(6, false, Rank.FIRST), + Arguments.of(6, true, Rank.FIRST), + Arguments.of(5, true, Rank.SECOND), + Arguments.of(5, false, Rank.THIRD), + Arguments.of(4, false, Rank.FOURTH), + Arguments.of(3, false, Rank.FIFTH), + Arguments.of(3, true, Rank.FIFTH) + ); + } + + @ParameterizedTest + @MethodSource("missCases") + void getRank_미당첨(int matched, boolean bonus) { + MatchResult matchResult = new MatchResult(matched, bonus); + assertThat(matchResult.getRank().name()).isIn("OOPS"); + } + + static Stream missCases() { + return Stream.of( + Arguments.of(2, true), + Arguments.of(2, false), + Arguments.of(1, false), + Arguments.of(0, false) + ); + } +} diff --git a/src/test/java/lotto/domain/RankTest.java b/src/test/java/lotto/domain/RankTest.java new file mode 100644 index 0000000000..0ffb668209 --- /dev/null +++ b/src/test/java/lotto/domain/RankTest.java @@ -0,0 +1,50 @@ +package lotto.domain; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +class RankTest { + @DisplayName("매칭 수와보너스 여부에 따라 올바른 Rank로 매핑된다") + @ParameterizedTest + @MethodSource("mappingCases") + void 랭크_매핑(int matched, boolean bonus, Rank expected) { + assertThat(Rank.findRankByMatchCountAndBonus(matched, bonus)).isEqualTo(expected); + } + + static Stream mappingCases() { + return Stream.of( + Arguments.of(6, false, Rank.FIRST), + Arguments.of(6, true, Rank.FIRST), + Arguments.of(5, true, Rank.SECOND), + Arguments.of(5, false, Rank.THIRD), + Arguments.of(4, false, Rank.FOURTH), + Arguments.of(3, false, Rank.FIFTH), + Arguments.of(2, true, Rank.OOPS), + Arguments.of(0, false, Rank.OOPS) + ); + } + + @DisplayName("각 Rank의 상금이 요구사항과 일치한다") + @ParameterizedTest + @MethodSource("prizeCases") + void 각_Rank의_상금이_요구사항과_일치한다(Rank rank, long expectedPrize) { + assertThat(rank.getPrize()).isEqualTo(expectedPrize); + } + + static Stream prizeCases() { + return Stream.of( + Arguments.of(Rank.FIRST, 2_000_000_000L), + Arguments.of(Rank.SECOND, 30_000_000L), + Arguments.of(Rank.THIRD, 1_500_000L), + Arguments.of(Rank.FOURTH, 50_000L), + Arguments.of(Rank.FIFTH, 5_000L), + Arguments.of(Rank.OOPS, 0L) + ); + } +} \ No newline at end of file diff --git a/src/test/java/lotto/domain/WinningNumbersTest.java b/src/test/java/lotto/domain/WinningNumbersTest.java new file mode 100644 index 0000000000..c95d424515 --- /dev/null +++ b/src/test/java/lotto/domain/WinningNumbersTest.java @@ -0,0 +1,63 @@ +package lotto.domain; + +import lotto.support.ErrorMessages; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; + +class WinningNumbersTest { + @Test + void 당첨번호가_6개가_아니면_예외() { + assertThatThrownBy(() -> new WinningNumbers(List.of(1, 2, 3, 4, 5), 7)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + + assertThatThrownBy(() -> new WinningNumbers(List.of(1, 2, 3, 4, 5, 6, 7), 8)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @Test + void 당첨번호가_범위를_벗어나면_예외() { + assertThatThrownBy(() -> new WinningNumbers(List.of(0, 2, 3, 4, 5, 6), 7)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + + assertThatThrownBy(() -> new WinningNumbers(List.of(1, 2, 3, 4, 5, 46), 7)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @Test + void 당첨번호에_중복이_있으면_예외() { + assertThatThrownBy(() -> new WinningNumbers(List.of(1, 1, 3, 4, 5, 6), 7)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @Test + void 보너스번호가_범위를_벗어나면_예외() { + assertThatThrownBy(() -> new WinningNumbers(List.of(1, 2, 3, 4, 5, 6), 0)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + + assertThatThrownBy(() -> new WinningNumbers(List.of(1, 2, 3, 4, 5, 6), 46)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @Test + void 보너스번호가_메인번호와_중복이면_예외() { + assertThatThrownBy(() -> new WinningNumbers(List.of(1, 2, 3, 4, 5, 6), 6)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @Test + void 당첨번호는_오름차순_정렬되어_보관() { + WinningNumbers winningNumbers = new WinningNumbers(List.of(8, 1, 6, 3, 2, 5), 7); + assertThat(winningNumbers.getNumbers()).containsExactly(1, 2, 3, 5, 6, 8); + } +} \ No newline at end of file diff --git a/src/test/java/lotto/service/ResultCalculatorTest.java b/src/test/java/lotto/service/ResultCalculatorTest.java new file mode 100644 index 0000000000..148f8c1816 --- /dev/null +++ b/src/test/java/lotto/service/ResultCalculatorTest.java @@ -0,0 +1,72 @@ +package lotto.service; + +import lotto.domain.*; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class ResultCalculatorTest { + + private static Lotto lotto(int a, int b, int c, int d, int e, int f) { + return new Lotto(List.of(a, b, c, d, e, f)); + } + + @DisplayName("등수별 개수/총상금/수익률 계산") + @Test + void 종합_집계() { + WinningNumbers winning = new WinningNumbers(List.of(1,2,3,4,5,6), 7); + List tickets = List.of( + lotto(1,2,3,4,5,6), + lotto(1,2,3,4,5,7), + lotto(1,2,3,4,5,8), + lotto(1,2,3,4,9,10), + lotto(1,2,3,11,12,13), + lotto(10,20,21,22,23,24) + ); + long purchase = tickets.size() * 1000L; + + WinningStatistics stats = ResultCalculator.calculate(tickets, winning, purchase); + + assertThat(stats.getCount(Rank.FIRST)).isEqualTo(1); + assertThat(stats.getCount(Rank.SECOND)).isEqualTo(1); + assertThat(stats.getCount(Rank.THIRD)).isEqualTo(1); + assertThat(stats.getCount(Rank.FOURTH)).isEqualTo(1); + assertThat(stats.getCount(Rank.FIFTH)).isEqualTo(1); + + assertThat(stats.getTotalPrize()).isEqualTo(2_031_555_000L); + + BigDecimal percent = stats.getRoiPercent(); + assertThat(percent.scale()).isEqualTo(1); + + String formatted = stats.formatRoiPercent(); + assertThat(formatted).matches("[0-9,]+\\.[0-9]%"); + } + + @DisplayName("수익률 반올림") + @Test + void 수익률_반올림_예시() { + WinningNumbers winning = new WinningNumbers(List.of(1,2,3,4,5,6), 7); + List tickets = List.of( + lotto(1,3,5,7,40,41), + lotto(10,11,12,13,14,15), + lotto(16,17,18,19,20,21), + lotto(22,23,24,25,26,27), + lotto(28,29,30,31,32,33), + lotto(34,35,36,37,38,39), + lotto(40,41,42,43,44,45), + lotto(8,10,12, 14, 16 ,18) + ); + long purchase = 8_000L; + + WinningStatistics stats = ResultCalculator.calculate(tickets, winning, purchase); + + assertThat(stats.getCount(Rank.FIFTH)).isEqualTo(1); + assertThat(stats.getTotalPrize()).isEqualTo(5_000L); + assertThat(stats.getRoiPercent()).isEqualByComparingTo(new BigDecimal("62.5")); + assertThat(stats.formatRoiPercent()).isEqualTo("62.5%"); + } +} \ No newline at end of file diff --git a/src/test/java/lotto/validator/BonusValidatorTest.java b/src/test/java/lotto/validator/BonusValidatorTest.java new file mode 100644 index 0000000000..1222efefb0 --- /dev/null +++ b/src/test/java/lotto/validator/BonusValidatorTest.java @@ -0,0 +1,37 @@ +package lotto.validator; + +import lotto.support.ErrorMessages; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class BonusValidatorTest { + + @ParameterizedTest + @ValueSource(ints = {0, 46, -1, 100}) + void bonus_범위_예외(int bonus) { + assertThatThrownBy(() -> BonusValidator.validateBonusRange(bonus)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @ParameterizedTest + @ValueSource(ints = {1, 2, 3, 4, 5, 6}) + void bonus_중복_예외(int bonus) { + assertThatThrownBy(() -> BonusValidator.validateBonusDuplicate(Arrays.asList(1, 2, 3, 4, 5, 6), bonus)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageStartingWith(ErrorMessages.PREFIX); + } + + @ParameterizedTest + @ValueSource(ints = {7, 9, 45}) + void bonus_정상_통과(int bonus) { + assertThatCode(() -> { + BonusValidator.validateBonus(Arrays.asList(1, 2, 3, 4, 5, 6), bonus); + }).doesNotThrowAnyException(); + } +} \ No newline at end of file