diff --git a/README.md b/README.md index cb44fbb4e3..905d38c7bd 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ - **[1단계 - 문자열 계산기](./docs/01-string-calculator.md)** - **[2단계 - 로또(자동)](./docs/02-lotto-auto.md)** - **[3단계 - 로또(2등)](./docs/03-lotto-rank-second.md)** -- **[4단계 - 로또(수동)]()** +- **[4단계 - 로또(수동)](./docs/04-lotto-manual.md)** ## 진행 방법 * 로또 요구사항을 파악한다. * 요구사항에 대한 구현을 완료한 후 자신의 github 아이디에 해당하는 브랜치에 Pull Request(이하 PR)를 통해 코드 리뷰 요청을 한다. diff --git a/docs/04-lotto-manual.md b/docs/04-lotto-manual.md new file mode 100644 index 0000000000..a1c3403938 --- /dev/null +++ b/docs/04-lotto-manual.md @@ -0,0 +1,123 @@ +# 4단계 - 로또(수동) +*** +## 코드 리뷰 +> PR 링크: +> **[https://github.com/next-step/java-lotto/pull/4232](https://github.com/next-step/java-lotto/pull/4232)** +## 나의 학습 목표 +- TDD 사이클로 구현 +- 객체지향 생활 체조 원칙 준수 +- 테스트 작성하기 쉬운 구조로 설계 +- 체크리스트 및 피드백 준수 +- 기능 추가 시 컴파일 에러를 최소화하며 점진적인 리팩터링에 도전 +## 기능 요구 사항 +- 현재 로또 생성기는 자동 생성 기능만 제공한다. 사용자가 수동으로 추첨 번호를 입력할 수 있도록 해야 한다. +- 입력한 금액, 자동 생성 숫자, 수동 생성 번호를 입력하도록 해야 한다. +## 힌트 +- 규칙 3: 모든 원시값과 문자열을 포장한다. + - 로또 숫자 하나는 int 타입이다. 이 숫자 하나를 추상화한 LottoNo 객체를 추가해 구현한다. +- 예외 처리를 통해 프로그램이 중단되지 않고 다시 입력할 수 있도록 구현한다. + - 사용자가 잘못된 값을 입력했을 때 java exception으로 에러 처리를 한다. + - java8에 추가된 Optional을 적용해 NullPointerException이 발생하지 않도록 한다. +## 프로그래밍 요구사항 +- 규칙 3: 모든 원시값과 문자열을 포장한다. +- 규칙 5: 줄여쓰지 않는다(축약 금지). +- 예외 처리를 통해 에러가 발생하지 않도록 한다. +- 모든 기능을 TDD로 구현해 단위 테스트가 존재해야 한다. 단, UI(System.out, System.in) 로직은 제외 +- java enum을 적용해 프로그래밍을 구현한다. +- 규칙 8: 일급 콜렉션을 쓴다. +- indent(인덴트, 들여쓰기) depth를 2를 넘지 않도록 구현한다. 1까지만 허용한다. +- 함수(또는 메소드)의 길이가 15라인을 넘어가지 않도록 구현한다. +- 자바 코드 컨벤션을 지키면서 프로그래밍한다. +- else 예약어를 쓰지 않는다. +## PR 전 점검 +**[체크리스트 확인하기](checklist.md)** +## 구현 기능 목록 +### 공통 +- [x] 금액 (Money) + - [x] 0원 이상의 정수로 생성 + - [x] 금액 비교 (미만 여부) + - [x] 나눗셈 가능 여부 판단 + - [x] 나눗셈 연산 + - [x] 비율 계산 + - [x] 곱셈 연산 + - [x] 덧셈 연산 + - [x] 원화 문자열 변환 + +### 로또 번호 +- [x] 로또 번호 (LottoNumber) + - [x] 1~45 범위의 정수로 생성 + - [x] 문자열 입력으로 생성 + - [x] 범위 벗어날 시 예외 발생 + - [x] 동일 값은 캐싱된 인스턴스 반환 + +### 로또 +- [x] 로또 (Lotto) + - [x] 6개의 로또 번호로 생성 + - [x] 중복된 번호 존재 시 예외 발생 + - [x] 번호 누락 시 예외 발생 + - [x] 6개가 아닐 시 예외 발생 + - [x] 다른 로또와 일치하는 번호 개수 계산 + - [x] 특정 번호 포함 여부 판단 + - [x] 정렬된 출력용 문자열 반환 + +### 로또 구매 +- [x] 로또 구매 개수 (LottoCount) + - [x] 0 이상의 정수로 생성 + - [x] 문자열 입력으로 생성 + - [x] 빈 문자열일 시 예외 발생 + - [x] 음수일 시 예외 발생 + - [x] 다른 개수와 차감 연산 + - [x] 차감 결과가 음수일 시 예외 발생 + +- [x] 로또 구매 금액 (LottoPurchaseAmount) + - [x] 금액으로 생성 + - [x] 문자열 입력으로 생성 + - [x] 정수 입력으로 생성 + - [x] 최소 금액(1,000원) 미달 시 예외 발생 + - [x] 1,000원 단위가 아닐 시 예외 발생 + - [x] 구매 가능한 로또 개수 계산 + +- [x] 구매한 로또 목록 (PurchasedLottos) + - [x] 로또 목록으로 생성 + - [x] 빈 목록일 시 예외 발생 + - [x] 당첨 로또와 비교하여 결과 생성 + +### 당첨 판정 +- [x] 당첨 로또 (WinningLotto) + - [x] 로또와 보너스 번호로 생성 + - [x] 보너스 번호가 당첨 번호와 중복 시 예외 발생 + - [x] 구매 로또와 비교하여 등수 판정 + +- [x] 등수 (LottoRank) + - [x] 일치 개수와 보너스 일치 여부로 등수 결정 + - [x] 3개 미만 일치 시 MISS 반환 + - [x] 3개 일치 시 FIFTH (5,000원) + - [x] 4개 일치 시 FOURTH (50,000원) + - [x] 5개 일치 시 THIRD (1,500,000원) + - [x] 5개 일치 + 보너스 일치 시 SECOND (30,000,000원) + - [x] 6개 일치 시 FIRST (2,000,000,000원) + - [x] 등수별 총 상금 계산 + - [x] 일치 개수 출력용 문자열 반환 + - [x] 상금 출력용 문자열 반환 + +- [x] 로또 결과 (LottoResult) + - [x] 빈 상태로 생성 + - [x] 등수 추가 + - [x] 총 상금 계산 + - [x] 등수별 결과 출력용 문자열 반환 + +- [x] 수익률 (ReturnRate) + - [x] 구매금액과 총 상금으로 생성 + - [x] 수익률 0 미만일 시 예외 발생 + - [x] 수익률 출력용 문자열 반환 + +### 유틸리티 +- [x] 자동 로또 생성기 (AutoBasedLottoGenerator) + - [x] 1~45 범위의 번호 풀 보유 + - [x] 지정 개수만큼 로또 생성 + - [x] 셔플 방식으로 6개 번호 선택 +- [x] 수동 로또 생성기 (ManualBasedLottoGenerator) + - [x] 문자열 목록을 입력받아 로또 목록 생성 + +- [x] 로또 번호 파서 (LottoNumberParser) + - [x] 쉼표 구분 문자열을 로또 번호 목록으로 변환 \ No newline at end of file diff --git a/src/main/java/lotto/Application.java b/src/main/java/lotto/Application.java index da7c70e75a..bcc923e8a4 100644 --- a/src/main/java/lotto/Application.java +++ b/src/main/java/lotto/Application.java @@ -1,38 +1,100 @@ package lotto; +import static lotto.view.InputView.*; +import static lotto.view.ResultView.*; + +import java.util.ArrayList; import java.util.List; +import java.util.function.Supplier; import lotto.domain.*; import lotto.utils.AutoBasedLottoGenerator; import lotto.utils.LottoNumberParser; -import lotto.view.InputView; -import lotto.view.ResultView; +import lotto.utils.ManualBasedLottoGenerator; public class Application { public static void main(String[] args) { - LottoPurchaseAmount purchaseAmount = getPurchaseAmount(); - PurchasedLottos purchased = new PurchasedLottos(generateLottos(purchaseAmount)); + LottoPurchaseAmount purchaseAmount = createPurchaseAmount(); + PurchasedLottos purchased = purchaseLottos(purchaseAmount); + + printPurchasedLottos(purchased); + + LottoResult result = purchased.result(createWinningLotto()); + + printResult(result, purchaseAmount); + } + + private static LottoPurchaseAmount createPurchaseAmount() { + return retryUntilSuccess(() -> new LottoPurchaseAmount(readPurchaseAmount())); + } + + private static PurchasedLottos purchaseLottos(LottoPurchaseAmount purchaseAmount) { + LottoCount totalCount = purchaseAmount.lottoCount(); + LottoCount manualCount = createManualLottoCount(totalCount); + LottoCount autoCount = totalCount.subtract(manualCount); + + printPurchaseCount(manualCount, autoCount); + + return mergeLottos(createManualLottos(manualCount), generateAutoLottos(autoCount)); + } + + private static LottoCount createManualLottoCount(LottoCount totalCount) { + return retryUntilSuccess(() -> { + LottoCount manualCount = new LottoCount(readManualLottoCount()); + totalCount.validateSubtractable(manualCount); + return manualCount; + }); + } + + private static List createManualLottos(LottoCount manualCount) { + return retryUntilSuccess( + () -> new ManualBasedLottoGenerator().generate(readManualLottoNumbers(manualCount.value()))); + } - ResultView.printPurchaseCount(purchased); - ResultView.printPurchasedLottos(purchased); + private static List generateAutoLottos(LottoCount autoCount) { + return new AutoBasedLottoGenerator().generate(autoCount); + } - LottoResult result = purchased.result(getWinningLotto()); + private static PurchasedLottos mergeLottos(List manual, List auto) { + List all = new ArrayList<>(manual); + all.addAll(auto); + return new PurchasedLottos(all); + } - ResultView.printResult(result, purchaseAmount); + private static WinningLotto createWinningLotto() { + Lotto lotto = createLottoForWinning(); + LottoNumber bonus = createBonusNumber(lotto); + return new WinningLotto(lotto, bonus); } - private static LottoPurchaseAmount getPurchaseAmount() { - return new LottoPurchaseAmount(InputView.readPurchaseAmount()); + private static Lotto createLottoForWinning() { + return retryUntilSuccess(() -> new Lotto(createWinningNumbers())); } - private static List generateLottos(LottoPurchaseAmount purchaseAmount) { - return new AutoBasedLottoGenerator().generate(purchaseAmount.lottoCount()); + private static List createWinningNumbers() { + return new LottoNumberParser().parse(readWinningNumbers()); } - private static WinningLotto getWinningLotto() { - String winningNumbers = InputView.readWinningNumbers(); - String bonusNumber = InputView.readBonusNumber(); + private static LottoNumber createBonusNumber(Lotto lotto) { + return retryUntilSuccess(() -> { + LottoNumber bonus = LottoNumber.of(readBonusNumber()); + validateBonusNotDuplicated(lotto, bonus); + return bonus; + }); + } + + private static void validateBonusNotDuplicated(Lotto lotto, LottoNumber bonus) { + if (lotto.contains(bonus)) { + throw new IllegalArgumentException("보너스 번호는 당첨 번호와 중복될 수 없습니다."); + } + } - List numbers = new LottoNumberParser().parse(winningNumbers); - return new WinningLotto(numbers, bonusNumber); + private static T retryUntilSuccess(Supplier action) { + while (true) { + try { + return action.get(); + } catch (IllegalArgumentException e) { + System.out.println(e.getMessage()); + } + } } } diff --git a/src/main/java/lotto/domain/LottoCount.java b/src/main/java/lotto/domain/LottoCount.java index cd6c2b444e..18e9e03d6a 100644 --- a/src/main/java/lotto/domain/LottoCount.java +++ b/src/main/java/lotto/domain/LottoCount.java @@ -1,11 +1,36 @@ package lotto.domain; public record LottoCount(int value) { - private static final int MIN_COUNT = 1; + + public LottoCount(String input) { + this(toInt(input)); + } + + private static int toInt(String input) { + if (input == null || input.isBlank()) { + throw new IllegalArgumentException("구매 개수는 필수입니다."); + } + return Integer.parseInt(input); + } public LottoCount { - if (value < MIN_COUNT) { - throw new IllegalArgumentException("로또 구매 개수는 %d개 이상이어야 합니다.".formatted(MIN_COUNT)); + if (value < 0) { + throw new IllegalArgumentException("구매 개수는 0이상이어야 합니다."); + } + } + + public LottoCount subtract(LottoCount other) { + validateSubtractable(other); + return new LottoCount(this.value - other.value); + } + + public void validateSubtractable(LottoCount other) { + if (isLessThan(other)) { + throw new IllegalArgumentException("차감할 수 없습니다."); } } + + private boolean isLessThan(LottoCount other) { + return this.value < other.value; + } } diff --git a/src/main/java/lotto/domain/PurchasedLottos.java b/src/main/java/lotto/domain/PurchasedLottos.java index 5c6a176950..250247c2b2 100644 --- a/src/main/java/lotto/domain/PurchasedLottos.java +++ b/src/main/java/lotto/domain/PurchasedLottos.java @@ -27,12 +27,4 @@ public LottoResult result(WinningLotto winningLotto) { return result; } - - public String purchaseCountForDisplay() { - return "%d개를 구매했습니다.".formatted(size()); - } - - private int size() { - return values.size(); - } } diff --git a/src/main/java/lotto/domain/WinningLotto.java b/src/main/java/lotto/domain/WinningLotto.java index 475b815747..581a273eb3 100644 --- a/src/main/java/lotto/domain/WinningLotto.java +++ b/src/main/java/lotto/domain/WinningLotto.java @@ -1,13 +1,7 @@ package lotto.domain; -import java.util.List; - public record WinningLotto(Lotto lotto, LottoNumber bonus) { - public WinningLotto(List numbers, String bonus) { - this(new Lotto(numbers), LottoNumber.of(bonus)); - } - public WinningLotto(Lotto lotto, int bonus) { this(lotto, LottoNumber.of(bonus)); } diff --git a/src/main/java/lotto/utils/ManualBasedLottoGenerator.java b/src/main/java/lotto/utils/ManualBasedLottoGenerator.java new file mode 100644 index 0000000000..11ecf89620 --- /dev/null +++ b/src/main/java/lotto/utils/ManualBasedLottoGenerator.java @@ -0,0 +1,21 @@ +package lotto.utils; + +import java.util.List; +import lotto.domain.Lotto; + +public class ManualBasedLottoGenerator { + + private final LottoNumberParser parser; + + public ManualBasedLottoGenerator() { + this(new LottoNumberParser()); + } + + private ManualBasedLottoGenerator(LottoNumberParser parser) { + this.parser = parser; + } + + public List generate(List inputs) { + return inputs.stream().map(parser::parse).map(Lotto::new).toList(); + } +} diff --git a/src/main/java/lotto/view/InputView.java b/src/main/java/lotto/view/InputView.java index 6a1b581352..2af7bdc8bb 100644 --- a/src/main/java/lotto/view/InputView.java +++ b/src/main/java/lotto/view/InputView.java @@ -1,6 +1,8 @@ package lotto.view; +import java.util.List; import java.util.Scanner; +import java.util.stream.IntStream; public class InputView { private static final Scanner scanner = new Scanner(System.in); @@ -11,6 +13,18 @@ public static String readPurchaseAmount() { return scanner.nextLine(); } + public static String readManualLottoCount() { + System.out.println("수동으로 구매할 로또 수를 입력해 주세요."); + + return scanner.nextLine(); + } + + public static List readManualLottoNumbers(int count) { + System.out.println("수동으로 구매할 번호를 입력해 주세요."); + + return IntStream.range(0, count).mapToObj(i -> scanner.nextLine()).toList(); + } + public static String readWinningNumbers() { System.out.println("지난 주 당첨 번호를 입력해 주세요."); diff --git a/src/main/java/lotto/view/ResultView.java b/src/main/java/lotto/view/ResultView.java index bd6c06e391..c843c82414 100644 --- a/src/main/java/lotto/view/ResultView.java +++ b/src/main/java/lotto/view/ResultView.java @@ -4,8 +4,8 @@ public class ResultView { - public static void printPurchaseCount(PurchasedLottos purchased) { - System.out.println(purchased.purchaseCountForDisplay()); + public static void printPurchaseCount(LottoCount manualCount, LottoCount auto) { + System.out.printf("수동으로 %d장, 자동으로 %d개를 구매했습니다.%n", manualCount.value(), auto.value()); } public static void printPurchasedLottos(PurchasedLottos purchased) { diff --git a/src/test/java/lotto/domain/LottoCountTest.java b/src/test/java/lotto/domain/LottoCountTest.java index 3454ca0b29..da97fd7e85 100644 --- a/src/test/java/lotto/domain/LottoCountTest.java +++ b/src/test/java/lotto/domain/LottoCountTest.java @@ -5,19 +5,60 @@ import org.junit.jupiter.api.DisplayNameGeneration; import org.junit.jupiter.api.DisplayNameGenerator; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; @DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) class LottoCountTest { + @ParameterizedTest(name = "입력값:{0}") + @ValueSource(ints = {0, 5}) + void 생성자_정상값_생성성공(int input) { + assertThatCode(() -> new LottoCount(input)).doesNotThrowAnyException(); + } + + @ParameterizedTest(name = "빈값:{0}") + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 생성자_빈문자열_예외발생(String input) { + assertThatThrownBy(() -> new LottoCount(input)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("구매 개수는 필수입니다."); + } + + @Test + void 생성자_음수입력_예외발생() { + assertThatThrownBy(() -> new LottoCount(-1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("구매 개수는 0이상이어야 합니다."); + } + + @ParameterizedTest(name = "원본:{0} - 차감:{1} = 결과:{2}") + @CsvSource({"10, 3, 7", "5, 5, 0"}) + void subtract_정상값_차감(int original, int subtrahend, int expected) { + assertThat(new LottoCount(original).subtract(new LottoCount(subtrahend))) + .isEqualTo(new LottoCount(expected)); + } + + @Test + void subtract_초과값_예외발생() { + assertThatThrownBy(() -> new LottoCount(3).subtract(new LottoCount(5))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("차감할 수 없습니다."); + } + @Test - void 생성자_정상값_생성성공() { - assertThatCode(() -> new LottoCount(1)).doesNotThrowAnyException(); + void validateSubtractable_정상값_예외없음() { + assertThatCode(() -> new LottoCount(10).validateSubtractable(new LottoCount(5))) + .doesNotThrowAnyException(); } @Test - void 생성자_1미만입력_예외발생() { - assertThatThrownBy(() -> new LottoCount(0)) + void validateSubtractable_초과값_예외발생() { + assertThatThrownBy(() -> new LottoCount(3).validateSubtractable(new LottoCount(5))) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("로또 구매 개수는 1개 이상이어야 합니다."); + .hasMessage("차감할 수 없습니다."); } } diff --git a/src/test/java/lotto/domain/PurchasedLottosTest.java b/src/test/java/lotto/domain/PurchasedLottosTest.java index 688a67a292..a1f7b9ec8b 100644 --- a/src/test/java/lotto/domain/PurchasedLottosTest.java +++ b/src/test/java/lotto/domain/PurchasedLottosTest.java @@ -24,11 +24,4 @@ class PurchasedLottosTest { .isInstanceOf(IllegalArgumentException.class) .hasMessage("구매한 로또는 1개 이상이어야 합니다."); } - - @Test - void purchaseCountForDisplay_구매개수_표시() { - PurchasedLottos purchased = new PurchasedLottos(new Lotto(1, 2, 3, 4, 5, 6)); - - assertThat(purchased.purchaseCountForDisplay()).isEqualTo("1개를 구매했습니다."); - } } diff --git a/src/test/java/lotto/utils/ManualBasedLottoGeneratorTest.java b/src/test/java/lotto/utils/ManualBasedLottoGeneratorTest.java new file mode 100644 index 0000000000..f60e8e2719 --- /dev/null +++ b/src/test/java/lotto/utils/ManualBasedLottoGeneratorTest.java @@ -0,0 +1,19 @@ +package lotto.utils; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import lotto.domain.Lotto; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +class ManualBasedLottoGeneratorTest { + + @Test + void generate_문자열목록_생성() { + assertThat(new ManualBasedLottoGenerator().generate(List.of("1, 2, 3, 4, 5, 6", "7, 8, 9, 10, 11, 12"))) + .isEqualTo(List.of(new Lotto(1, 2, 3, 4, 5, 6), new Lotto(7, 8, 9, 10, 11, 12))); + } +}