diff --git a/README.md b/README.md index 15bb106b5..611ccd7fa 100644 --- a/README.md +++ b/README.md @@ -1 +1,116 @@ # javascript-lotto-precourse + +# 프리코스-로또 + +## 이번 미션에서는 도전하고 싶은 부분 + +1. MVC 패턴 숙지 +2. Airbnb js 코드 컨벤션 적용 +3. 메서드의 책임 최소화 +4. 테스트 코드 원칙에 의거한 테스트 코드 구현 + +--- + +### **1. 모델 (Model): 데이터 관리의 핵심** + +- **모델의 역할:** + - **데이터 저장 및 관리:** 애플리케이션에서 사용하는 데이터를 데이터베이스에 저장하고, 필요할 때 데이터를 가져오거나 수정, 삭제하는 역할을 합니다. + - **데이터베이스와 상호작용:** 데이터베이스와 직접 통신하여 데이터를 처리합니다. (예: 테이블 생성, 데이터 검색, 데이터 삽입 등) + - **데이터 유효성 검증:** 데이터가 올바른 형식인지 확인하고, 잘못된 데이터가 저장되지 않도록 합니다. (예: 이메일 형식 검사, 필수 입력 필드 확인) + - **비즈니스 로직 처리:** 데이터와 관련된 복잡한 규칙이나 계산을 처리합니다. (예: 할인율 계산, 사용자 등급 계산 등) + +### **2. 뷰 (View): 사용자 인터페이스 담당** + +- **뷰의 역할:** + - **사용자 인터페이스 생성:** 웹 페이지를 구성하는 HTML, CSS, JavaScript 코드를 작성하여 사용자에게 보여지는 화면을 만듭니다. + - **데이터 출력:** 모델에서 가져온 데이터를 사용자에게 보기 좋게 출력합니다. + - **템플릿 사용:** HTML에 Ruby 코드를 추가하여 동적인 웹 페이지를 만듭니다. (ERB, Haml 등의 템플릿 엔진 사용) + - **사용자 입력 처리:** 사용자가 입력한 데이터를 화면에서 처리하고, 컨트롤러에 전달합니다. +- **뷰의 예시 (Rails):** + - `app/views` 폴더에 있는 파일 (예: `users/index.html.erb`, `posts/show.html.erb`) + - ERB(Embedded Ruby) 문법을 사용하여 HTML 코드 안에 Ruby 코드를 작성할 수 있습니다. +- **뷰의 특징:** + - 웹 브라우저에 보여지는 HTML, CSS, JavaScript 코드를 포함합니다. + - 모델에서 가져온 데이터를 예쁘게 표현합니다. + - 사용자의 입력에 따라 동적으로 웹 페이지를 변경합니다. + +**뷰를 쉽게 이해하는 방법:** + +- **무대 디자이너:** 뷰는 마치 무대 디자이너와 같습니다. 사용자에게 보여지는 화면을 디자인하고 꾸밉니다. +- **프레젠테이션 전문가:** 뷰는 데이터를 프레젠테이션하는 전문가입니다. 데이터를 보기 좋게 정리하여 보여줍니다. + +### **3. 컨트롤러 (Controller): 모델과 뷰 연결 담당** + +- **컨트롤러의 역할:** + - **사용자 요청 처리:** 웹 브라우저에서 온 사용자 요청을 분석하고, 어떤 모델을 사용해야 하는지, 어떤 뷰를 보여줘야 하는지 결정합니다. + - **모델 호출:** 모델을 사용하여 데이터를 가져오거나 변경합니다. + - **뷰 호출:** 데이터를 뷰에 전달하고, 뷰를 실행하여 사용자에게 웹 페이지를 보여줍니다. + - **흐름 제어:** 웹 애플리케이션의 흐름을 제어합니다. (예: 로그인 여부 확인, 사용 권한 확인 등) + - **데이터 전달:** 모델에서 가져온 데이터를 뷰에 전달합니다. +- **컨트롤러의 예시 (Rails):** + - `app/controllers` 폴더에 있는 파일 (예: `UsersController.rb`, `PostsController.rb`) + - 액션(action)이라고 불리는 메서드를 사용하여 요청을 처리합니다. +- **컨트롤러의 특징:** + - 모델과 뷰 사이의 중개자 역할을 합니다. + - 사용자의 요청에 따라 적절한 모델과 뷰를 선택합니다. + - 웹 애플리케이션의 논리적인 흐름을 제어합니다. + +**컨트롤러를 쉽게 이해하는 방법:** + +- **교통 경찰관:** 컨트롤러는 마치 교통 경찰관과 같습니다. 교통 흐름을 제어하고, 필요에 따라 차량(데이터)을 목적지(뷰)로 안내합니다. +- **매니저:** 컨트롤러는 웹 애플리케이션의 매니저와 같습니다. 사용자의 요청을 처리하고, 필요한 작업을 수행합니다. + +--- + +미션에 따른 역할 구분 + +- 1개의 로또를 발행할 때 중복되지 않는 6개의 숫자를 뽑는다. + 중복되지 않는 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원이다. + 이건 상수로 묶어서 처리 +- 당첨 번호와 보너스 번호를 입력받는다. + 입력된 값에 따라 데이터를 뷰로 보내기 때문에 있으므로 컨트롤러에 해당 +- 사용자가 구매한 로또 번호와 당첨 번호를 비교하여 당첨 내역 및 수익률을 출력하고 로또 게임을 종료한다. +- 사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 `Error`를 발생시키고 해당 메시지를 출력한 다음 해당 지점부터 다시 입력을 받는다. + +--- + +- 입력값 숫자로 변경 +- 시행횟수가 0보다 작을경우 또는 0일경우 예외처리 +- 입력금액보다 게임 하는 금액이 클 경우 예외처리 +- 에러 메시지 상수화 + +### Money 클래스에서 해야할일 + +- 입력받은 금액을 문자열에서 숫자로 변경 +- 일어날 수 있는 문제에 대한 예외처리 +- 최대 가능 횟수 계산 +- 거스름돈 계산 + +### Lotto 클래스 해야할일 + +- 생성된 번호 정렬 -> 컨트롤러에서 제공 +- 매직넘버와 생성된 번호를 비교후 불리언 값으로 비교 -> 계산기에서 비교 +- 입력받은 배열을 숫자 배열로 변경 -> + +### LottoGenerator 클래스의 역할 + +- 랜덤 번호 생성 +- 티켓 구매 수 만큼 랜덤 번호 시행 회수 + +### Calculator 클래스의 역할 + +- 배열수를 확인 +- 배열에 맞게 맞는 수가 있다면 카운트 증가 +- 카운트를 배경으로 등수를 매김 diff --git a/__tests__/CalculatorTest.js b/__tests__/CalculatorTest.js new file mode 100644 index 000000000..45d2886d9 --- /dev/null +++ b/__tests__/CalculatorTest.js @@ -0,0 +1,39 @@ +import Calculator from "../src/model/Calculator.js"; + +const makeTicket = (numbers) => ({ + getNumbers: () => numbers, +}); + +describe("Calculator - tallyResults", () => { + test("6개 일치 -> rank1 1개", () => { + const tickets = [makeTicket([1, 2, 3, 4, 5, 6])]; + const tally = Calculator.tallyResults(tickets, [1, 2, 3, 4, 5, 6], 7); + expect(tally).toEqual({ rank5: 0, rank4: 0, rank3: 0, rank2: 0, rank1: 1 }); + }); + + test("5개+보너스 -> rank2 1개", () => { + const tickets = [makeTicket([1, 2, 3, 4, 5, 7])]; + const tally = Calculator.tallyResults(tickets, [1, 2, 3, 4, 5, 6], 7); + expect(tally).toEqual({ rank5: 0, rank4: 0, rank3: 0, rank2: 1, rank1: 0 }); + }); + + test("5개 -> rank3 1개", () => { + const tickets = [makeTicket([1, 2, 3, 4, 5, 8])]; + const tally = Calculator.tallyResults(tickets, [1, 2, 3, 4, 5, 6], 7); + expect(tally).toEqual({ rank5: 0, rank4: 0, rank3: 1, rank2: 0, rank1: 0 }); + }); + + test("4개 -> rank4 1개", () => { + const tickets = [makeTicket([1, 2, 3, 4, 9, 10])]; + const tally = Calculator.tallyResults(tickets, [1, 2, 3, 4, 5, 6], 7); + expect(tally).toEqual({ rank5: 0, rank4: 1, rank3: 0, rank2: 0, rank1: 0 }); + }); + + test("3개 -> rank5 1개", () => { + const tickets = [makeTicket([1, 2, 3, 9, 10, 11])]; + const tally = Calculator.tallyResults(tickets, [1, 2, 3, 4, 5, 6], 7); + expect(tally).toEqual({ rank5: 1, rank4: 0, rank3: 0, rank2: 0, rank1: 0 }); + }); +}); + + diff --git a/src/App.js b/src/App.js index 091aa0a5d..c8eb1e427 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,67 @@ +import { Console } from "@woowacourse/mission-utils"; +import Inputs from "./controller/Inputs.js"; +import Money from "./model/money.js"; +import LottoGenerator from "./model/LottoGenerator.js"; +import Calculator from "./model/Calculator.js"; +import PRIZE_RANK from "./constants/prizeRank.js"; + class App { - async run() {} + async run() { + const money = await this.#readValidMoney(); + const count = money.getPurchaseCount(); + Console.print(`${count}개를 구매했습니다.`); + + const tickets = LottoGenerator.generateTickets(count); + tickets.forEach((ticket) => { + const formatted = `[${ticket.getNumbers().join(", ")}]`; + Console.print(formatted); + }); + + const winningNumbers = await Inputs.getWinningNumber(); + const bonusNumber = await Inputs.getBonusNumber(); + + const tally = Calculator.tallyResults( + tickets, + winningNumbers, + bonusNumber + ); + + this.#printStatistics(tally); + const totalPrize = Calculator.totalPrize(tally); + const rate = Calculator.rate(totalPrize, money.getAmount()); + Console.print(`총 수익률은 ${rate}%입니다.`); + } + + async #readValidMoney() { + while (true) { + try { + const input = await Inputs.getMoney(); + return new Money(input); + } catch (e) { + Console.print(e?.message); + } + } + } + + #printStatistics(tally) { + const format = (key, desc) => { + const amount = desc.split(" / ")[1]; + const labelMap = { + NO_5: "3개 일치", + NO_4: "4개 일치", + NO_3: "5개 일치", + NO_2: "5개 일치, 보너스 볼 일치", + NO_1: "6개 일치", + }; + return `${labelMap[key]} (${amount})`; + }; + + Console.print(`${format("NO_5", PRIZE_RANK.NO_5)} - ${tally.rank5}개`); + Console.print(`${format("NO_4", PRIZE_RANK.NO_4)} - ${tally.rank4}개`); + Console.print(`${format("NO_3", PRIZE_RANK.NO_3)} - ${tally.rank3}개`); + Console.print(`${format("NO_2", PRIZE_RANK.NO_2)} - ${tally.rank2}개`); + Console.print(`${format("NO_1", PRIZE_RANK.NO_1)} - ${tally.rank1}개`); + } } export default App; diff --git a/src/Lotto.js b/src/Lotto.js index cb0b1527e..d37d90f34 100644 --- a/src/Lotto.js +++ b/src/Lotto.js @@ -1,18 +1,34 @@ +import GAME_RULES from "./constants/game.js"; + class Lotto { #numbers; constructor(numbers) { this.#validate(numbers); - this.#numbers = numbers; + this.#numbers = this.#sortNumbers(numbers); } #validate(numbers) { - if (numbers.length !== 6) { + if (numbers.length !== GAME_RULES.LOTTO_NUMBER_COUNT) { throw new Error("[ERROR] 로또 번호는 6개여야 합니다."); } + const uniqueCount = new Set(numbers).size; + if (uniqueCount !== numbers.length) { + throw new Error("[ERROR] 중복된 번호가 있습니다."); + } + } + + #sortNumbers(numbers) { + const SORTED_TICKET = [...numbers].sort((a, b) => a - b); + + return SORTED_TICKET; } - // TODO: 추가 기능 구현 + getNumbers() { + return [...this.#numbers]; + } } export default Lotto; + + diff --git a/src/constants/errorMessages.js b/src/constants/errorMessages.js new file mode 100644 index 000000000..dc66c912b --- /dev/null +++ b/src/constants/errorMessages.js @@ -0,0 +1,10 @@ +const ERROR_MESSAGES = { + NOT_NUMBER: "[ERROR] 금액은 숫자여야 합니다.", + BELOW_MINIMUM: "[ERROR] 최소 금액은 1,000원 이상이어야 합니다.", + INVALID_UNIT: "[ERROR] 금액은 1,000원 단위로 입력해야 합니다.", + INVALID_COUNT: "[ERROR] 구매 개수는 1개 이상이어야 합니다.", + EXCEED_LIMIT: "[ERROR] 구매 개수가 금액을 초과할 수 없습니다.", + LOTTO_NUMBER_COUNT: "[ERROR] 로또 번호는 6개여야 합니다.", +}; + +export default ERROR_MESSAGES; diff --git a/src/constants/game.js b/src/constants/game.js new file mode 100644 index 000000000..57c513ded --- /dev/null +++ b/src/constants/game.js @@ -0,0 +1,7 @@ +const GAME_RULES = { + LOTTO_NUMBER_COUNT: 6, + MIN_NUMBER: 1, + MAX_NUMBER: 45, +}; + +export default GAME_RULES; diff --git a/src/constants/prizeRank.js b/src/constants/prizeRank.js new file mode 100644 index 000000000..72d4ed548 --- /dev/null +++ b/src/constants/prizeRank.js @@ -0,0 +1,9 @@ +const PRIZE_RANK = { + NO_1: "6개 번호 일치 / 2,000,000,000원", + NO_2: "5개 번호 + 보너스 번호 일치 / 30,000,000원", + NO_3: "5개 번호 일치 / 1,500,000원", + NO_4: "4개 번호 일치 / 50,000원", + NO_5: "3개 번호 일치 / 5,000원", +}; + +export default PRIZE_RANK; diff --git a/src/controller/Inputs.js b/src/controller/Inputs.js new file mode 100644 index 000000000..fc598a35e --- /dev/null +++ b/src/controller/Inputs.js @@ -0,0 +1,48 @@ +import { Console } from "@woowacourse/mission-utils"; + +export default class Inputs { + static async getWinningNumber() { + const INPUT = await Console.readLineAsync("당첨 번호를 입력해 주세요. : "); + const TRIMED_INPUT = INPUT.replaceAll(" ", ""); + const WINNING_NUMBER = TRIMED_INPUT.split(","); + + return this.#changeToNumber(WINNING_NUMBER).sort((a, b) => a - b); + } + + static async getBonusNumber() { + const BONUS_NUMBER = await Console.readLineAsync( + "보너스 번호를 입력해 주세요. : " + ); + + return Number(BONUS_NUMBER); + } + + static async getMoney() { + const MONEY_INPUT = await Console.readLineAsync("금액을 입력해 주세요. : "); + + return MONEY_INPUT; + } + + static async getGameCount() { + const COUNT_INPUT = await Console.readLineAsync( + "시행횟수를 입력해 주세요. : " + ); + + return COUNT_INPUT; + } + + static async getPurchaseMoney() { + const PURCHASE_MONEY = await Console.readLineAsync( + "구매 금액을 입력해 주세요. : " + ); + + return PURCHASE_MONEY; + } + + static #changeToNumber(numbers) { + const CHECK_NUM = numbers.map((index) => { + return Number(index); + }); + return CHECK_NUM; + } +} diff --git a/src/model/Calculator.js b/src/model/Calculator.js new file mode 100644 index 000000000..8edecb6d5 --- /dev/null +++ b/src/model/Calculator.js @@ -0,0 +1,70 @@ +import PRIZE_RANK from "../constants/prizeRank.js"; + +const PRIZE ={ + RANK_1: 2000000000, + RANK_2: 30000000, + RANK_3: 1500000, + RANK_4: 50000, + RANK_5: 5000, +}; + +export default class Calculator { + static countMatch(ticketNumbers, winningNumbers) { + const set = new Set(winningNumbers); + return ticketNumbers.filter((n) => set.has(n)).length; + } + + static isBonusMatch(ticketNumbers, bonus) { + return ticketNumbers.includes(bonus); + } + + static getRank(ticketNumbers, winningNumbers, bonus) { + const match = Calculator.countMatch(ticketNumbers, winningNumbers); + if (match === 6) return "rank1"; + if (match === 5 && Calculator.isBonusMatch(ticketNumbers, bonus)) return "rank2"; + if (match === 5) return "rank3"; + if (match === 4) return "rank4"; + if (match === 3) return "rank5"; + return null; + } + + static tallyResults(tickets, winningNumbers, bonus) { + return tickets.reduce( + (tally, ticket) => { + const rank = Calculator.getRank( + ticket.getNumbers(), + winningNumbers, + bonus + ); + if (rank) tally[rank] += 1; + return tally; + }, + { rank5: 0, rank4: 0, rank3: 0, rank2: 0, rank1: 0 } + ); + } + + static totalPrize(tally) { + return ( + tally.rank1 * PRIZE.RANK_1 + + tally.rank2 * PRIZE.RANK_2 + + tally.rank3 * PRIZE.RANK_3 + + tally.rank4 * PRIZE.RANK_4 + + tally.rank5 * PRIZE.RANK_5 + ); + } + + static rate(totalPrize, purchaseAmount) { + const percent = (totalPrize / purchaseAmount) * 100; + return Math.round(percent * 10) / 10; + } + + static rankDescriptions() { + return { + rank1: PRIZE_RANK.NO_1, + rank2: PRIZE_RANK.NO_2, + rank3: PRIZE_RANK.NO_3, + rank4: PRIZE_RANK.NO_4, + rank5: PRIZE_RANK.NO_5, + }; + } +} diff --git a/src/model/LottoGenerator.js b/src/model/LottoGenerator.js new file mode 100644 index 000000000..ecd689c32 --- /dev/null +++ b/src/model/LottoGenerator.js @@ -0,0 +1,26 @@ +import { Random } from "@woowacourse/mission-utils"; +import GAME_RULES from "../constants/game.js"; +import Lotto from "../Lotto.js"; + +class LottoGenerator { + static generateTicket() { + const GET_TICKET = Random.pickUniqueNumbersInRange( + GAME_RULES.MIN_NUMBER, + GAME_RULES.MAX_NUMBER, + GAME_RULES.LOTTO_NUMBER_COUNT + ); + + return new Lotto(GET_TICKET); + } + + static generateTickets(count) { + const tickets = []; + for (let i = 0; i < count; i += 1) { + tickets.push(this.generateTicket()); + } + return tickets; + } + +} + +export default LottoGenerator; diff --git a/src/model/money.js b/src/model/money.js new file mode 100644 index 000000000..e2f734345 --- /dev/null +++ b/src/model/money.js @@ -0,0 +1,35 @@ +import ERROR_MESSAGES from "../constants/errorMessages.js"; + +export default class Money { + #amount; + static #GAME_PRICE = 1000; + + constructor(input) { + const parsed = Money.#parseAmount(input); + Money.#validateAmount(parsed); + this.#amount = parsed; + } + + static #parseAmount(input) { + const num = Number(input); + if (Number.isNaN(num)) { + throw new Error(ERROR_MESSAGES.NOT_NUMBER); + } + return num; + } + + static #validateAmount(amount) { + if (amount < Money.#GAME_PRICE) + throw new Error(ERROR_MESSAGES.BELOW_MINIMUM); + if (amount % Money.#GAME_PRICE !== 0) + throw new Error(ERROR_MESSAGES.INVALID_UNIT); + } + + getAmount() { + return this.#amount; + } + + getPurchaseCount() { + return this.#amount / Money.#GAME_PRICE; + } +}