diff --git a/.gitignore b/.gitignore index 5dca701a77..68c92da03d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,35 +1,46 @@ -HELP.md -.gradle -build/ +# ===================================== +# (Java / Gradle / IntelliJ 프로젝트 기준) +# ===================================== + +# === 빌드 & 실행 결과물 === +/build/ +out/ +bin/ +.gradle/ !gradle/wrapper/gradle-wrapper.jar -!**/src/main/** -!**/src/test/** - -### STS ### -.apt_generated -.classpath -.factorypath -.project -.settings -.springBeans -.sts4-cache - -### IntelliJ IDEA ### -.idea -*.iws + +# === IntelliJ IDEA 설정 === +.idea/ *.iml *.ipr -out/ - -### NetBeans ### -/nbproject/private/ -/nbbuild/ -/dist/ -/nbdist/ -/.nb-gradle/ +*.iws -### VS Code ### +# === VSCode (혹시 사용할 경우) === .vscode/ -### Mac OS ### +# === OS 자동 생성 파일 === .DS_Store +Thumbs.db + +# === 로그 & 캐시 파일 === +*.log +*.tmp +*.bak +*.swp + +# === 컴파일 산출물 === +*.class +*.jar +*.war +*.ear + +# === 테스트 관련 === +/test-output/ +/reports/ + +# === 환경변수, 개인 설정 === +*.env +*.local + +# === 백업 파일 === +*~ diff --git a/README.md b/README.md index 5fa2560b46..6d68391c3d 100644 --- a/README.md +++ b/README.md @@ -1 +1,91 @@ -# java-lotto-precourse +# 🎰 로또 + +--- +## ⚙️ 구현 목록 + +### 입력 +- [x] 구입 금액 입력 (콘솔 입력, 1000원 단위 검증) +- [x] 당첨 번호 6개 입력 (쉼표 구분) +- [x] 보너스 번호 1개 입력 + +### 로또 로직 +- [ ] 구입 금액만큼 로또 생성 +- [ ] 당첨 번호 및 보너스 번호 저장 +- [ ] 구매한 로또 번호 비교 +- [ ] 일치 및 보너스 여부에 따른 등수 계산 +- [ ] 당첨 내역 및 수익률 계산 + +### 출력 +- [ ] 구매한 로또 수량 및 번호 출력 +- [ ] 당첨 내역 및 등수별 결과 출력 +- [ ] 총 수익률 출력 (소수점 둘째 자리, nn.n%) + + +### 예외 처리 +- [ ] 잘못된 입력 시 `[ERROR]`로 시작하는 에러 메시지 출력 후 재입력 + +--- + +## 🧪 실행 예시 + +```markdown +구입금액을 입력해 주세요. +8000 + +8개를 구매했습니다. +[8, 21, 23, 41, 42, 43] +[3, 5, 11, 16, 32, 38] +... + +당첨 번호를 입력해 주세요. +1,2,3,4,5,6 +보너스 번호를 입력해 주세요. +7 + +당첨 통계 + +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%입니다. +``` +--- + +## 🧩 단위 테스트 계획 + +### LottoTest +- [x] 로또 번호 개수가 6개가 아니면 예외 +- [x] 로또 번호에 중복된 숫자가 있으면 예외 발생 +- [x] 로또 번호가 1~45 범위를 벗어나면 예외 발생 + +### RankTest +- [x] 일치 개수가 6개면 1등으로 판단 +- [x] 일치 개수가 5개이고 보너스 번호 일치 시 2등으로 판단 +- [x] 일치 개수가 5개이고 보너스 번호 불일치 시 3등으로 판단 +- [x] 일치 개수가 4개면 4등으로 판단 +- [x] 일치 개수가 3개면 5등으로 판단 +- [x] 일치 개수가 2개 이하일 경우 당첨 아님 처리 + +### LottoResultTest +- [x] 여러 로또의 당첨 결과 집계가 정확한지 확인 +- [x] 수익률 계산이 정확한지 검증 (소수점 둘째 자리 반올림) + +### LottoMachineTest +- [x] 구입 금액에 따라 로또가 알맞게 생성되는지 확인 +- [x] 생성된 모든 로또가 중복되지 않고 유효한 번호를 가지는지 검증 + +--- + +## ✅ 피드백 기반 미니 퀘스트 + +### 🚗 2주차 피드백 반영 (✅ 이미 완료된 항목 제외) +- **테스트 코드 작성 및 단위 분리** + - 이번 주는 본격적으로 도메인 중심 단위 테스트를 작성한다. +- **테스트의 목적을 명확히 이해** + - “동작 확인”이 아니라 “의도 검증” 중심으로 작성 +- **입력/출력(UI)과 로직 분리** + - 기존 분리 구조 유지, 로또 로직은 도메인 중심으로 구성 + +--- diff --git a/src/main/java/lotto/Application.java b/src/main/java/lotto/Application.java index d190922ba4..5dfc3010ca 100644 --- a/src/main/java/lotto/Application.java +++ b/src/main/java/lotto/Application.java @@ -1,7 +1,104 @@ package lotto; +import camp.nextstep.edu.missionutils.Console; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + public class Application { public static void main(String[] args) { - // TODO: 프로그램 구현 + LottoMachine lottoMachine = new LottoMachine(); + OutputView outputView = new OutputView(); + LottoResult lottoResult = new LottoResult(); + + List lottos; + int money; + while (true) { + try { + System.out.println("구입금액을 입력해 주세요."); + String inputMoney = Console.readLine(); + money = parseNumber(inputMoney); + + lottos = lottoMachine.purchaseLottos(money); + + outputView.printPurchaseCount(lottos.size()); + outputView.printLottos(lottos); + break; + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + + Lotto winningLotto; + while (true) { + try { + System.out.println("\n당첨 번호를 입력해 주세요."); + String inputWinningNumbers = Console.readLine(); + List numbers = parseWinningNumbers(inputWinningNumbers); + winningLotto = new Lotto(numbers); + break; + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + + int bonusNumber; + while (true) { + try { + System.out.println("\n보너스 번호를 입력해 주세요."); + String inputBonusNumber = Console.readLine(); + bonusNumber = parseNumber(inputBonusNumber); + validateBonusNumber(winningLotto, bonusNumber); + break; + } catch (IllegalArgumentException e) { + outputView.printError(e.getMessage()); + } + } + + for (Lotto lotto : lottos) { + int matchCount = calculateMatchCount(lotto, winningLotto); + boolean bonusMatch = lotto.getNumbers().contains(bonusNumber); + + Rank rank = Rank.determineRank(matchCount, bonusMatch); + lottoResult.addResult(rank); + } + + outputView.printStatistics(lottoResult); + double rateOfReturn = lottoResult.calculateRateOfReturn(money); + outputView.printRateOfReturn(rateOfReturn); + } + + private static List parseWinningNumbers(String input) { + try { + return Stream.of(input.split(",")) + .map(String::trim) + .map(Integer::parseInt) + .collect(Collectors.toList()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("[ERROR] 당첨 번호는 숫자여야 합니다."); + } + } + + private static int parseNumber(String input) { + try { + return Integer.parseInt(input.trim()); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("[ERROR] 숫자를 입력해야 합니다."); + } + } + + private static void validateBonusNumber(Lotto winningLotto, int bonusNumber) { + if (bonusNumber < 1 || bonusNumber > 45) { + throw new IllegalArgumentException("[ERROR] 보너스 번호는 1부터 45 사이의 숫자여야 합니다."); + } + if (winningLotto.getNumbers().contains(bonusNumber)) { + throw new IllegalArgumentException("[ERROR] 보너스 번호는 당첨 번호와 중복될 수 없습니다."); + } + } + + private static int calculateMatchCount(Lotto userLotto, Lotto winningLotto) { + return (int) userLotto.getNumbers().stream() + .filter(winningLotto.getNumbers()::contains) + .count(); } -} +} \ No newline at end of file diff --git a/src/main/java/lotto/Lotto.java b/src/main/java/lotto/Lotto.java index 88fc5cf12b..691c63820f 100644 --- a/src/main/java/lotto/Lotto.java +++ b/src/main/java/lotto/Lotto.java @@ -1,20 +1,39 @@ package lotto; +import java.util.HashSet; import java.util.List; +import java.util.Set; public class Lotto { + private static final int LOTTO_SIZE = 6; + private static final int MIN_NUMBER = 1; + private static final int MAX_NUMBER = 45; + private final List numbers; public Lotto(List numbers) { validate(numbers); - this.numbers = numbers; + this.numbers = List.copyOf(numbers); } private void validate(List numbers) { - if (numbers.size() != 6) { - throw new IllegalArgumentException("[ERROR] 로또 번호는 6개여야 합니다."); + if (numbers.size() != LOTTO_SIZE) { + throw new IllegalArgumentException("로또 번호는 6개여야 합니다"); + } + + Set unique = new HashSet<>(numbers); + if (unique.size() != LOTTO_SIZE) { + throw new IllegalArgumentException("중복된 숫자는 허용되지 않습니다"); + } + + boolean outOfRange = numbers.stream() + .anyMatch(n -> n < MIN_NUMBER || n > MAX_NUMBER); + if (outOfRange) { + throw new IllegalArgumentException("로또 번호는 1부터 45 사이여야 합니다"); } } - // TODO: 추가 기능 구현 + public List getNumbers() { + return numbers; + } } diff --git a/src/main/java/lotto/LottoMachine.java b/src/main/java/lotto/LottoMachine.java new file mode 100644 index 0000000000..d33388ea5e --- /dev/null +++ b/src/main/java/lotto/LottoMachine.java @@ -0,0 +1,38 @@ +package lotto; + +import camp.nextstep.edu.missionutils.Randoms; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class LottoMachine { + public static final int LOTTO_PRICE = 1000; + public static final int LOTTO_MAX_PRICE = 100000; + + public List purchaseLottos(int purchaseAmount) { + validatePurchaseAmount(purchaseAmount); + int numberOfLottos = purchaseAmount / LOTTO_PRICE; + + List lottos = new ArrayList<>(); + for (int i = 0; i < numberOfLottos; i++) { + lottos.add(createLotto()); + } + return lottos; + } + + private Lotto createLotto() { + List numbers = Randoms.pickUniqueNumbersInRange(1, 45, 6); + + List sortedNumbers = new ArrayList<>(numbers); + Collections.sort(sortedNumbers); + + return new Lotto(sortedNumbers); + } + + private void validatePurchaseAmount(int purchaseAmount) { + if (purchaseAmount <= 0 || purchaseAmount % LOTTO_PRICE != 0) + throw new IllegalArgumentException("[ERROR] 구입 금액은 1,000원 단위여야 합니다."); + if (purchaseAmount > LOTTO_MAX_PRICE) + throw new IllegalArgumentException("[ERROR] 1회 구매 금액은 10만 원을 초과할 수 없습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/lotto/LottoResult.java b/src/main/java/lotto/LottoResult.java new file mode 100644 index 0000000000..aa7f13b405 --- /dev/null +++ b/src/main/java/lotto/LottoResult.java @@ -0,0 +1,42 @@ +package lotto; + +import java.util.EnumMap; +import java.util.Map; + +public class LottoResult { + + private final Map resultCounts; + + public LottoResult() { + this.resultCounts = new EnumMap<>(Rank.class); + for (Rank rank : Rank.values()) { + resultCounts.put(rank, 0); + } + } + + public void addResult(Rank rank) { + resultCounts.put(rank, resultCounts.get(rank) + 1); + } + + public int getCount(Rank rank) { + return resultCounts.get(rank); + } + + private double calculateTotalPrize() { + double totalPrize = 0; + for (Rank rank : resultCounts.keySet()) { + totalPrize += (double) rank.getPrizeMoney() * resultCounts.get(rank); + } + return totalPrize; + } + + public double calculateRateOfReturn(int purchaseAmount) { + double totalPrize = calculateTotalPrize(); + if (purchaseAmount == 0) { + return 0.0; + } + + double rate = (totalPrize / purchaseAmount) * 100.0; + return Math.round(rate * 10.0) / 10.0; + } +} \ No newline at end of file diff --git a/src/main/java/lotto/OutputView.java b/src/main/java/lotto/OutputView.java new file mode 100644 index 0000000000..a0a7edf11c --- /dev/null +++ b/src/main/java/lotto/OutputView.java @@ -0,0 +1,44 @@ +package lotto; + +import java.util.List; + +public class OutputView { + + public void printPurchaseCount(int count) { + System.out.println("\n" + count + "개를 구매했습니다."); + } + + public void printLottos(List lottos) { + for (Lotto lotto : lottos) { + System.out.println(lotto.getNumbers().toString()); + } + } + + public void printStatistics(LottoResult lottoResult) { + System.out.println("\n당첨 통계"); + System.out.println("---"); + + List ranksToPrint = List.of( + Rank.FIFTH, + Rank.FOURTH, + Rank.THIRD, + Rank.SECOND, + Rank.FIRST + ); + + for (Rank rank : ranksToPrint) { + String message = rank.getMessage(); // "3개 일치 (5,000원)" + int count = lottoResult.getCount(rank); + + System.out.println(message + " - " + count + "개"); + } + } + + public void printRateOfReturn(double rate) { + System.out.printf("총 수익률은 %.1f%%입니다.\n", rate); + } + + public void printError(String message) { + System.out.println(message); + } +} \ No newline at end of file diff --git a/src/main/java/lotto/Rank.java b/src/main/java/lotto/Rank.java new file mode 100644 index 0000000000..76db0e2f6a --- /dev/null +++ b/src/main/java/lotto/Rank.java @@ -0,0 +1,41 @@ +package lotto; + +public enum Rank { + // 3개 일치부터 6개 일치까지 순서대로 정의 + FIFTH(5_000, "3개 일치 (5,000원)"), + FOURTH(50_000, "4개 일치 (50,000원)"), + THIRD(1_500_000, "5개 일치 (1,500,000원)"), + SECOND(30_000_000, "5개 일치, 보너스 볼 일치 (30,000,000원)"), + FIRST(2_000_000_000, "6개 일치 (2,000,000,000원)"), + ZERO(0, ""); + + private final int prizeMoney; + private final String message; + + Rank(int prizeMoney, String message) { + this.prizeMoney = prizeMoney; + this.message = message; + } + + public int getPrizeMoney() { + return prizeMoney; + } + + public String getMessage() { + return message; + } + + public static Rank determineRank(int matchRank, boolean bonusRank) { + if (matchRank == 6) + return FIRST; + if (matchRank == 5 && bonusRank) + return SECOND; + if (matchRank == 5) + return THIRD; + if (matchRank == 4) + return FOURTH; + if (matchRank == 3) + return FIFTH; + return ZERO; + } +} \ No newline at end of file diff --git a/src/test/java/lotto/ApplicationTest.java b/src/test/java/lotto/ApplicationTest.java index a15c7d1f52..8889c73306 100644 --- a/src/test/java/lotto/ApplicationTest.java +++ b/src/test/java/lotto/ApplicationTest.java @@ -13,7 +13,7 @@ class ApplicationTest extends NsTest { private static final String ERROR_MESSAGE = "[ERROR]"; @Test - void 기능_테스트() { + void Function_Test() { assertRandomUniqueNumbersInRangeTest( () -> { run("8000", "1,2,3,4,5,6", "7"); @@ -47,7 +47,7 @@ class ApplicationTest extends NsTest { } @Test - void 예외_테스트() { + void Exception_Test() { assertSimpleTest(() -> { runException("1000j"); assertThat(output()).contains(ERROR_MESSAGE); diff --git a/src/test/java/lotto/LottoMachineTest.java b/src/test/java/lotto/LottoMachineTest.java new file mode 100644 index 0000000000..64cafd3e48 --- /dev/null +++ b/src/test/java/lotto/LottoMachineTest.java @@ -0,0 +1,59 @@ +package lotto; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LottoMachineTest { + + private LottoMachine lottoMachine; + + @BeforeEach + void setUp() { + lottoMachine = new LottoMachine(); + } + + @Test + void PurchaseAmount_Test() { + int money = 8000; + + List lottos = lottoMachine.purchaseLottos(money); + assertThat(lottos.size()).isEqualTo(8); + } + + @Test + void InvalidPurchaseAmount_Test() { + int money = 1500; + + assertThatThrownBy(() -> lottoMachine.purchaseLottos(money)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR] 구입 금액은 1,000원 단위여야 합니다."); + } + + @Test + void ZeroPurchaseAmount_Test() { + int money = 0; + + assertThatThrownBy(() -> lottoMachine.purchaseLottos(money)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("[ERROR] 구입 금액은 1,000원 단위여야 합니다."); + } + + @Test + void CreatedLottos_Validation_Test() { + int money = 1000; + + List lottos = lottoMachine.purchaseLottos(money); + + assertThat(lottos.size()).isEqualTo(1); + + Lotto lotto = lottos.get(0); + assertThat(lotto.getNumbers().size()).isEqualTo(6); + + List numbers = lotto.getNumbers(); + assertThat(numbers).isSorted(); + } +} \ No newline at end of file diff --git a/src/test/java/lotto/LottoResultTest.java b/src/test/java/lotto/LottoResultTest.java new file mode 100644 index 0000000000..77f605452c --- /dev/null +++ b/src/test/java/lotto/LottoResultTest.java @@ -0,0 +1,59 @@ +package lotto; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class LottoResultTest { + + private LottoResult lottoResult; + + @BeforeEach + void setUp() { + lottoResult = new LottoResult(); + } + + @Test + void ResultAggregation_Test() { + lottoResult.addResult(Rank.FIFTH); + lottoResult.addResult(Rank.THIRD); + lottoResult.addResult(Rank.ZERO); + lottoResult.addResult(Rank.ZERO); + + assertThat(lottoResult.getCount(Rank.FIFTH)).isEqualTo(1); + assertThat(lottoResult.getCount(Rank.THIRD)).isEqualTo(1); + assertThat(lottoResult.getCount(Rank.ZERO)).isEqualTo(2); + + assertThat(lottoResult.getCount(Rank.FIRST)).isEqualTo(0); + assertThat(lottoResult.getCount(Rank.SECOND)).isEqualTo(0); + assertThat(lottoResult.getCount(Rank.FOURTH)).isEqualTo(0); + } + + @Test + void RateOfReturn_Example_Test() { + lottoResult.addResult(Rank.FIFTH); + + double rate = lottoResult.calculateRateOfReturn(8000); + + assertThat(rate).isEqualTo(62.5); + } + + @Test + void RateOfReturn_Rounding_Test() { + lottoResult.addResult(Rank.FIFTH); + + double rate = lottoResult.calculateRateOfReturn(3000); + + assertThat(rate).isEqualTo(166.7); + } + + @Test + void RateOfReturn_ZeroPrize_Test() { + lottoResult.addResult(Rank.ZERO); + lottoResult.addResult(Rank.ZERO); + + double rate = lottoResult.calculateRateOfReturn(2000); + + assertThat(rate).isEqualTo(0.0); + } +} \ No newline at end of file diff --git a/src/test/java/lotto/LottoTest.java b/src/test/java/lotto/LottoTest.java index 309f4e50ae..4d45551a2c 100644 --- a/src/test/java/lotto/LottoTest.java +++ b/src/test/java/lotto/LottoTest.java @@ -1,25 +1,45 @@ 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; +import static org.assertj.core.api.Assertions.assertThat; +import java.util.List; class LottoTest { + @Test - void 로또_번호의_개수가_6개가_넘어가면_예외가_발생한다() { - assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 6, 7))) - .isInstanceOf(IllegalArgumentException.class); + void WrongQuantityNumbersOfLotto_Test() { + List numbers = List.of(1, 2, 3, 4, 5); + + assertThatThrownBy(() -> new Lotto(numbers)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("로또 번호는 6개여야 합니다"); } - @DisplayName("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.") @Test - void 로또_번호에_중복된_숫자가_있으면_예외가_발생한다() { - assertThatThrownBy(() -> new Lotto(List.of(1, 2, 3, 4, 5, 5))) - .isInstanceOf(IllegalArgumentException.class); + void DuplicateLotto_Test() { + List numbers = List.of(1, 2, 3, 3, 4, 5); + + assertThatThrownBy(() -> new Lotto(numbers)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("중복된 숫자는 허용되지 않습니다"); } - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 + @Test + void WrongRangeOfLotto_Test() { + List numbers = List.of(0, 2, 3, 4, 5, 6); + + assertThatThrownBy(() -> new Lotto(numbers)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("로또 번호는 1부터 45 사이여야 합니다"); + } + + @Test + void SuccessLotto_Test() { + List numbers = List.of(1, 2, 3, 4, 5, 6); + + Lotto lotto = new Lotto(numbers); + + assertThat(lotto.getNumbers()).containsExactly(1, 2, 3, 4, 5, 6); + } } diff --git a/src/test/java/lotto/RankTest.java b/src/test/java/lotto/RankTest.java new file mode 100644 index 0000000000..35a2cb9ff7 --- /dev/null +++ b/src/test/java/lotto/RankTest.java @@ -0,0 +1,43 @@ +package lotto; + +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class RankTest { + + @Test + void FirstRank_Test() { + assertThat(Rank.determineRank(6, true)).isEqualTo(Rank.FIRST); + assertThat(Rank.determineRank(6, false)).isEqualTo(Rank.FIRST); + } + + @Test + void SecondRank_Test() { + assertThat(Rank.determineRank(5, true)).isEqualTo(Rank.SECOND); + } + + @Test + void ThirdRank_Test() { + assertThat(Rank.determineRank(5, false)).isEqualTo(Rank.THIRD); + } + + @Test + void FourthRank_Test() { + assertThat(Rank.determineRank(4, true)).isEqualTo(Rank.FOURTH); + assertThat(Rank.determineRank(4, false)).isEqualTo(Rank.FOURTH); + } + + @Test + void FifthRank_Test() { + assertThat(Rank.determineRank(3, true)).isEqualTo(Rank.FIFTH); + assertThat(Rank.determineRank(3, false)).isEqualTo(Rank.FIFTH); + } + + @Test + void MissRank_Test() { + assertThat(Rank.determineRank(2, true)).isEqualTo(Rank.ZERO); + assertThat(Rank.determineRank(2, false)).isEqualTo(Rank.ZERO); + assertThat(Rank.determineRank(1, true)).isEqualTo(Rank.ZERO); + assertThat(Rank.determineRank(0, false)).isEqualTo(Rank.ZERO); + } +} \ No newline at end of file