diff --git a/README.md b/README.md index 15bb106b5..f039c9c94 100644 --- a/README.md +++ b/README.md @@ -1 +1,66 @@ # javascript-lotto-precourse +# 3주차 - 로또 + +## ⚙️ 간단한 프로젝트 실행 흐름 +1. 안내 문구를 출력한다. +2. 로또 구입 금액을 입력받아 검증한다.(1,000원 단위) +3. 구입 장수만큼 로또를 발행하고 오름차순으로 출력한다. +4. 당첨 번호(6개, 쉼표 구분)를 입력받아 검증한다. +5. 보너스 번호(1개)를 입력받아 검증한다. +6. 구매 로또 vs 담청 번호를 비교해 등수별 개수를 계산한다. +7. 총 수익률(소수점 둘째 자리 반올림)을 출력한다. +8. 게임을 종료한다. +(사용자가 잘못된 값을 입력할 경우 "[ERROR]로 시작하는 메시지와 함께 Error 발생 + 해당 메시지 출력 후 다음 해당 지점부터 다시 입력을 받는다.) + + +## ⚠️ 고려사항 +- 로또 번호의 숫자 범위는 1~45이다. +- 로또 번호 6개와 보너스 번호 1개는 중복되지 않는다. +- 로또 구입 금액 입력 시, 그 금액에 해당하는 만큼 로또를 발행해야 한다. +- 로또 1장 가격 : 1,000원 + + +## 🔧 구현할 기능 목록 +### 1. util +- parser(문자열 -> 자료형 변환) + - [X] parseAmount : 구입 금액 문자열 -> 정수 + - [X] parseWinningNumbers : 로또 담청 번호 문자열 -> 정수 배열 + - [X] parseBonusNumber : 보너스 번호 문자열 -> 정수 + +- validator(입력 규칙 검증) + - [X] validateAmount : 정수 여부, 1000원 단위, 최소 1000원 + - [X] validateWinningNumbers : 길이 6, 1~45, 중복 없음 + - [X] validateBonusNumber : 1~45, 당첨 번호와 중복 불가 + +- format(출력 포맷) + - [X] formatTicket : `[1, 2, 5, 14, 22, 45]` 문자열화 + - [X] formatRate : 소수점 둘째 자리 반올림 퍼센트 `"62.5%"` + + +### 2. domain +- [X] Lotto 클래스 : 생성자에서 번호 검증 +- [X] Ranks, Prizes 상수 : 등수 규칙 및 상금 테이블 + + +### 3. service +- LottoService + - [X] issueTickets : 금액 -> 정수 계산, Random Api 통한 티켓 발행 + - [X] matchCounter : 교집합 개수 판정 + - [X] rankResolver : 보너스 포함 판정 + - [X] evaluate : 로또 번호 일치 개수 계산, 등수 판정, 집계 및 수익률 반환 + + +### 4. io +- [X] InputView +- [X] OutputView +- [X] 공통 에러 처리 + + +### 5. App.js +- [ ] 모든 모듈 연동 +- [ ] `App.run()` 실행 흐름 완성 + + +### 6. test +- [ ] LottoTest.js : 테스트 코드 추가 및 테스트 +- [ ] ApplicationTest.js : 테스트 코드 실행 \ No newline at end of file diff --git a/__tests__/LottoTest.js b/__tests__/LottoTest.js index 409aaf69b..621345ac2 100644 --- a/__tests__/LottoTest.js +++ b/__tests__/LottoTest.js @@ -7,12 +7,58 @@ describe("로또 클래스 테스트", () => { }).toThrow("[ERROR]"); }); - // TODO: 테스트가 통과하도록 프로덕션 코드 구현 test("로또 번호에 중복된 숫자가 있으면 예외가 발생한다.", () => { expect(() => { new Lotto([1, 2, 3, 4, 5, 5]); }).toThrow("[ERROR]"); }); - // TODO: 추가 기능 구현에 따른 테스트 코드 작성 + test("범위 밖(0 포함)이면 예외", () => { + expect(() => { + new Lotto([0, 2, 3, 4, 5, 6]); + }).toThrow("[ERROR]"); + }); + + test("범위 밖(46 포함)이면 예외", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 46]); + }).toThrow("[ERROR]"); + }); + + test("정수가 아니면(소수 포함) 예외", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5, 6.5]); + }).toThrow("[ERROR]"); + }); + + test("정수가 아니면(문자열 포함) 예외", () => { + expect(() => { + // 문자열 '1' 포함 → 정수 아님 → 예외 + new Lotto(["1", 2, 3, 4, 5, 6]); + }).toThrow("[ERROR]"); + }); + + test("내부 저장은 오름차순으로 정렬된다.", () => { + const lotto = new Lotto([6, 1, 3, 2, 5, 4]); + expect(lotto.getNumbers()).toEqual([1, 2, 3, 4, 5, 6]); + }); + + test("getNumbers는 불변성을 보장한다(복사본 반환).", () => { + const lotto = new Lotto([1, 2, 3, 4, 5, 6]); + const arr = lotto.getNumbers(); + arr[0] = 999; + expect(lotto.getNumbers()).toEqual([1, 2, 3, 4, 5, 6]); + }); + + test("경계값 1과 45는 유효하다.", () => { + expect(() => { + new Lotto([1, 3, 10, 20, 30, 45]); + }).not.toThrow(); + }); + + test("로또 번호의 개수가 6개 미만이면 예외가 발생한다.", () => { + expect(() => { + new Lotto([1, 2, 3, 4, 5]); + }).toThrow("[ERROR]"); + }); }); diff --git a/__tests__/ServiceTest.js b/__tests__/ServiceTest.js new file mode 100644 index 000000000..c9a779162 --- /dev/null +++ b/__tests__/ServiceTest.js @@ -0,0 +1,38 @@ +import { rankResolver, evaluate } from "../src/service/lottoService.js"; +import Lotto from "../src/Lotto"; +import { PRIZE } from "../src/constants.js"; + +describe("서비스 로직 테스트", () => { + test("rankResolver: 5개 + 보너스 일치면 SECOND", () => { + const ticket = new Lotto([1, 2, 3, 4, 5, 7]); // 보너스 7 포함 + const winning = [1, 2, 3, 4, 5, 6]; + const bonus = 7; + const rank = rankResolver(ticket.getNumbers(), winning, bonus); + expect(rank).toBe("SECOND"); + }); + + test("evaluate: 집계/상금 합계 계산", () => { + const tickets = [ + new Lotto([1, 2, 3, 4, 5, 6]), // FIRST + new Lotto([1, 2, 3, 4, 5, 7]), // SECOND (보너스 7) + new Lotto([1, 2, 3, 4, 10, 11]), // FOURTH + new Lotto([1, 2, 3, 20, 21, 22]), // FIFTH + new Lotto([40, 41, 42, 43, 44, 45]), // NONE + ]; + const winning = [1, 2, 3, 4, 5, 6]; + const bonus = 7; + + const { counts, totalPrize } = evaluate(tickets, winning, bonus); + + expect(counts).toEqual({ + FIRST: 1, + SECOND: 1, + THIRD: 0, + FOURTH: 1, + FIFTH: 1, + }); + + const expected = PRIZE.FIRST + PRIZE.SECOND + PRIZE.FOURTH + PRIZE.FIFTH; + expect(totalPrize).toBe(expected); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5d..ae1376dd5 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,20 @@ +import { askPurchaseAmount, askWinningNumbers, askBonusNumber} from "./io/inputView.js"; +import { printIssuedTickets, printEvaluationResults } from "./io/outView.js"; +import { issueTickets, evaluate } from "./service/lottoService.js"; + + class App { - async run() {} + async run() { + const amount = await askPurchaseAmount(); + const tickets = issueTickets(amount); + printIssuedTickets(tickets); + + const winningNumbers = await askWinningNumbers(); + const bonusNumber = await askBonusNumber(winningNumbers); + + const { counts, totalPrize } = evaluate(tickets, winningNumbers, bonusNumber); + printEvaluationResults(counts, totalPrize, amount); + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..cf2aa0d7d 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -1,18 +1,30 @@ +import { LOTTO_MIN, LOTTO_MAX, LOTTO_SIZE } from "./constants.js"; + class Lotto { #numbers; constructor(numbers) { this.#validate(numbers); - this.#numbers = numbers; + this.#numbers = [...numbers].sort((a, b) => a - b); } #validate(numbers) { - if (numbers.length !== 6) { + if (!Array.isArray(numbers) || numbers.length !== LOTTO_SIZE) { throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); } + const isRange = (number) => Number.isInteger(number) && LOTTO_MIN <= number && number <= LOTTO_MAX; + if (numbers.some(number => !isRange(number))) { + throw new Error(`[ERROR] 로또 번호는 ${LOTTO_MIN}부터 ${LOTTO_MAX} 사이의 숫자여야 합니다.`); + } + const set = new Set(numbers); + if (set.size !== numbers.length) { + throw new Error("[ERROR] 로또 번호는 중복될 수 없습니다."); + } } - // TODO: 추가 기능 구현 + getNumbers() { + return [...this.#numbers]; + } } export default Lotto; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 000000000..10bbe877d --- /dev/null +++ b/src/constants.js @@ -0,0 +1,22 @@ +export const LOTTO_MIN = 1; +export const LOTTO_MAX = 45; +export const LOTTO_SIZE = 6; +export const LOTTO_PRICE = 1000; + +// 등수/상금 테이블 +export const PRIZE = { + FIRST: 2000000000, // 6개 + SECOND: 30000000, // 5개 + 보너스 + THIRD: 1500000, // 5개 + FOURTH: 50000, // 4개 + FIFTH: 5000, // 3개 +}; + +// 출력 라벨 +export const RANK_LABEL = { + FIFTH: "3개 일치 (5,000원)", + FOURTH: "4개 일치 (50,000원)", + THIRD: "5개 일치 (1,500,000원)", + SECOND: "5개 일치, 보너스 볼 일치 (30,000,000원)", + FIRST: "6개 일치 (2,000,000,000원)", +}; \ No newline at end of file diff --git a/src/domain/Lotto.js b/src/domain/Lotto.js new file mode 100644 index 000000000..d0c9eae61 --- /dev/null +++ b/src/domain/Lotto.js @@ -0,0 +1,30 @@ +import { LOTTO_MIN, LOTTO_MAX, LOTTO_SIZE } from "../constants.js"; + +class Lotto { + #numbers; + + constructor(numbers) { + this.#validate(numbers); + this.#numbers = [...numbers].sort((a, b) => a - b); + } + + #validate(numbers) { + if (!Array.isArray(numbers) || numbers.length !== LOTTO_SIZE) { + throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); + } + const isRange = (number) => Number.isInteger(number) && LOTTO_MIN <= number && number <= LOTTO_MAX; + if (numbers.some(number => !isRange(number))) { + throw new Error(`[ERROR] 로또 번호는 ${LOTTO_MIN}부터 ${LOTTO_MAX} 사이의 숫자여야 합니다.`); + } + const set = new Set(numbers); + if (set.size !== numbers.length) { + throw new Error("[ERROR] 로또 번호는 중복될 수 없습니다."); + } + } + + getNumbers() { + return [...this.#numbers]; + } +} + +export default Lotto; diff --git a/src/io/error.js b/src/io/error.js new file mode 100644 index 000000000..ec7b63f8c --- /dev/null +++ b/src/io/error.js @@ -0,0 +1,20 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; + +export const printError = (error) => { + let raw = error; + if (raw === null || raw === undefined) { + raw = ""; + } + + let msg; + if (raw && typeof raw.message === 'string') { + msg = raw.message; + } else { + msg = String(raw); + } + + if (!msg.startsWith("[ERROR]")) { + msg = `[ERROR] ${msg}`; + } + MissionUtils.Console.print(msg); +}; \ No newline at end of file diff --git a/src/io/inputView.js b/src/io/inputView.js new file mode 100644 index 000000000..3285e36d8 --- /dev/null +++ b/src/io/inputView.js @@ -0,0 +1,39 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; +import { parseAmount, parseWinningNumbers, parseBonusNumber } from "../util/parser.js"; +import { validateAmount, validateWinningNumbers, validateBonusNumber } from '../util/validator.js'; +import { printError } from './error.js'; + +const ask = (question) => MissionUtils.Console.readLineAsync(question); + +export const askPurchaseAmount = async () => { + while (true) { + try { + const amountStr = await ask("구입금액을 입력해 주세요.\n"); + return validateAmount(parseAmount(amountStr)); + } catch (error) { + printError(error); + } + } +}; + +export const askWinningNumbers = async () => { + while (true) { + try { + const numbersStr = await ask("\n당첨 번호를 입력해 주세요.\n"); + return validateWinningNumbers(parseWinningNumbers(numbersStr)); + } catch (error) { + printError(error); + } + } +}; + +export const askBonusNumber = async (winningNumbers) => { + while (true) { + try { + const bonusStr = await ask("\n보너스 번호를 입력해 주세요.\n"); + return validateBonusNumber(parseBonusNumber(bonusStr), winningNumbers); + } catch (error) { + printError(error); + } + } +}; \ No newline at end of file diff --git a/src/io/outView.js b/src/io/outView.js new file mode 100644 index 000000000..e9a86ef98 --- /dev/null +++ b/src/io/outView.js @@ -0,0 +1,26 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; +import { formatTicket, formatRate } from '../util/format.js'; +import { RANK_LABEL } from '../constants.js'; + +const RANK_ORDER = ['FIFTH', 'FOURTH', 'THIRD', 'SECOND', 'FIRST']; + +export const printIssuedTickets = (tickets) => { + MissionUtils.Console.print(`\n${tickets.length}개를 구매했습니다.`); + for (const ticket of tickets) { + MissionUtils.Console.print(formatTicket(ticket.getNumbers())); + } +}; + +export const printEvaluationResults = (rankCounts, totalPrize, purchaseAmount) => { + MissionUtils.Console.print('\n당첨 통계'); + MissionUtils.Console.print('---'); + for (const r of RANK_ORDER) { + let c = 0; + if (rankCounts && rankCounts[r] !== undefined) { + c = rankCounts[r]; + } + MissionUtils.Console.print(`${RANK_LABEL[r]} - ${c}개`); + } + const rate = (totalPrize / purchaseAmount) * 100; + MissionUtils.Console.print(`총 수익률은 ${formatRate(rate)}입니다.`); +}; \ No newline at end of file diff --git a/src/service/lottoService.js b/src/service/lottoService.js new file mode 100644 index 000000000..f6f04377c --- /dev/null +++ b/src/service/lottoService.js @@ -0,0 +1,58 @@ +import {MissionUtils} from '@woowacourse/mission-utils'; +import { LOTTO_MIN, LOTTO_MAX, LOTTO_SIZE, LOTTO_PRICE, PRIZE } from "../constants.js"; +import Lotto from "../Lotto.js"; + +const sortAscending = (arr) => [...arr].sort((a, b) => a - b); + +export const issueTickets = (amount) => { + const count = amount / LOTTO_PRICE; + const tickets = []; + for (let i = 0; i < count; i += 1) { + const numbers = MissionUtils.Random.pickUniqueNumbersInRange(LOTTO_MIN, LOTTO_MAX, LOTTO_SIZE); + tickets.push(new Lotto(sortAscending(numbers))); + } + return tickets; +}; + +export const matchCounter = (ticket, winningNumbers) => { + const winSet = new Set(winningNumbers); + let count = 0; + for (const n of ticket) { + if (winSet.has(n)) { + count += 1; + } + } + return count; +}; + +export const rankResolver = (ticketNumbers, winningNumbers, bonusNumber) => { + const matchCount = matchCounter(ticketNumbers, winningNumbers); + if (matchCount === 6) return 'FIRST'; + if (matchCount === 5 && ticketNumbers.includes(bonusNumber)) return 'SECOND'; + if (matchCount === 5) return 'THIRD'; + if (matchCount === 4) return 'FOURTH'; + if (matchCount === 3) return 'FIFTH'; + return 'NONE'; +}; + +const computeTotalPrize = (rankCounts) => { + let total = 0; + total += rankCounts.FIRST * PRIZE.FIRST; + total += rankCounts.SECOND * PRIZE.SECOND; + total += rankCounts.THIRD * PRIZE.THIRD; + total += rankCounts.FOURTH * PRIZE.FOURTH; + total += rankCounts.FIFTH * PRIZE.FIFTH; + return total; +}; + +export const evaluate = (tickets, winningNumbers, bonusNumber) => { + const rankCounts = { FIRST: 0, SECOND: 0, THIRD: 0, FOURTH: 0, FIFTH: 0}; + for (const ticket of tickets) { + const rank = rankResolver(ticket.getNumbers(), winningNumbers, bonusNumber); + if (rankCounts[rank] !== undefined) rankCounts[rank] += 1; + } + const totalPrize = computeTotalPrize(rankCounts); + return { counts: rankCounts, totalPrize }; +}; + +export default { issueTickets, matchCounter, rankResolver, evaluate }; \ No newline at end of file diff --git a/src/util/format.js b/src/util/format.js new file mode 100644 index 000000000..c08d712c4 --- /dev/null +++ b/src/util/format.js @@ -0,0 +1,6 @@ +export const formatTicket = (numbers) => `[${numbers.join(", ")}]`; + +export const formatRate = (value) => { + const rounded = Math.round(value * 10) / 10; + return `${rounded}%`; +}; diff --git a/src/util/parser.js b/src/util/parser.js new file mode 100644 index 000000000..b0576e0f9 --- /dev/null +++ b/src/util/parser.js @@ -0,0 +1,25 @@ +import { LOTTO_SIZE } from "../constants.js"; + +export function parseAmount(amountStr) { + const amount = String(amountStr ?? "").trim(); + if (!/^\d+$/.test(amount)) { + throw new Error("구입 금액은 숫자여야 합니다."); + } + return Number(amount); +} + +export function parseWinningNumbers(numbersStr) { + const tokens = String(numbersStr ?? "").trim().split(",").map(num => num.trim()); + if (tokens.length !== LOTTO_SIZE || tokens.some(token => !/^\d+$/.test(token))) { + throw new Error(`당첨 번호는 쉼표로 구분된 ${LOTTO_SIZE}개의 숫자여야 합니다.`); + } + return tokens.map(Number); +} + +export function parseBonusNumber(bonusStr) { + const bonus = String(bonusStr ?? "").trim(); + if (!/^\d+$/.test(bonus)) { + throw new Error("보너스 번호는 숫자여야 합니다."); + } + return Number(bonus); +} diff --git a/src/util/validator.js b/src/util/validator.js new file mode 100644 index 000000000..23faf7359 --- /dev/null +++ b/src/util/validator.js @@ -0,0 +1,38 @@ +import { LOTTO_MIN, LOTTO_MAX, LOTTO_SIZE, LOTTO_PRICE } from "../constants.js"; + +const inRange = (number) => Number.isInteger(number) && LOTTO_MIN <= number && number <= LOTTO_MAX; + +export const validateAmount = (amount) => { + if (!Number.isInteger(amount)) { + throw new Error("구입 금액은 숫자여야 합니다."); + } + if (amount < LOTTO_PRICE || amount % LOTTO_PRICE !== 0) { + throw new Error("구입 금액은 1000원 단위여야 합니다."); + } + return amount; +} + +export const validateWinningNumbers = (numbers) => { + if (!Array.isArray(numbers) || numbers.length !== LOTTO_SIZE) { + throw new Error(`로또 번호는 ${LOTTO_SIZE}개의 숫자여야 합니다.`); + } + if (numbers.some(number => !inRange(number))) { + throw new Error(`로또 번호는 ${LOTTO_MIN}부터 ${LOTTO_MAX} 사이의 숫자여야 합니다.`); + } + const uniqueNumbers = new Set(numbers); + if (uniqueNumbers.size !== numbers.length) { + throw new Error("로또 번호는 중복될 수 없습니다."); + } + return numbers; +} + +export const validateBonusNumber = (bonusNumber, winningNumbers) => { + if (!inRange(bonusNumber)) { + throw new Error(`보너스 번호는 ${LOTTO_MIN}부터 ${LOTTO_MAX} 사이의 숫자여야 합니다.`); + } + const winSet = new Set(winningNumbers); + if (winSet.has(bonusNumber)) { + throw new Error("보너스 번호는 당첨 번호와 중복될 수 없습니다."); + } + return bonusNumber; +} \ No newline at end of file