-
Notifications
You must be signed in to change notification settings - Fork 192
[로또] 강성준 미션 제출합니다. #172
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
[로또] 강성준 미션 제출합니다. #172
Changes from all commits
ac70aed
5b738c5
d002db0
602187b
4df3b3a
467beb0
91d5617
e252496
2f25d74
8827cc3
15dc33e
c07e8c4
03fba68
10be6a8
6baad1f
3b9c13b
21af86c
99bab77
28bdf34
b2e5d21
b3cf446
219fd71
04163be
0295c78
d2d5566
e7df964
df46dd1
15fbced
a1cb95b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1,46 @@ | ||
| # javascript-lotto-precourse | ||
| # 로또 시뮬레이터 | ||
|
|
||
| # 기능 목록 | ||
| ### Pure(순수 계산: 계산/검증/도메인) | ||
| - [x] `입력값을 필요한 형태로 파싱 계산` | ||
| - [x] 구입 금액 문자열을 숫자 타입으로 변환한다. (재사용성 검토) | ||
| - [x] 구입 금액을 로또 1장의 가격으로 나눈다. | ||
| - [x] 로또 당첨 번호를 쉼표(,)를 기준으로 나누어 숫자 배열을 생성한다. | ||
| - [x] 보너스 번호 문자열을 숫자 타입으로 변환한다. | ||
| - [x] `사용자의 입력값 검증 계산` | ||
| - [x] 구입 금액이 1000 단위의 양의 정수로 이루어진 문자열인지 검증한다. | ||
| - [x] 로또 당첨 번호가 6개인지 검증한다. | ||
| - [x] 번호가 정수인지 검증한다. | ||
| - [x] 로또 당첨 번호가 중복되지 않는 지 검증한다. | ||
| - [x] 로또 당첨 번호가 1 <= n <= 45를 만족하는 정수인지 검증한다. | ||
| - [x] 보너스 번호가 1 <= n <= 45를 만족하는 정수인지 검증한다. | ||
| - [x] 보너스 번호가 당첨 번호와 중복되지 않는 지 검증한다. | ||
| - [x] `구매량 만큼 로또를 만드는 계산` | ||
| - [x] <당첨 번호 6개> 의 데이터를 생성한다. | ||
| - [x] 구매량 만큼의 로또 데이터를 생성한다. | ||
| - [x] `당첨 번호와 로또 번호를 비교/채점하는 계산` | ||
| - [x] 당첨 번호와 일치하는 번호의 수를 계산한다. | ||
| - [x] 보너스 번호가 로또 번호에 존재하는 지 계산한다. | ||
| - [x] 로또의 등수를 계산한다. | ||
| - [x] `로또 수익률을 계산` | ||
| - [x] 전체 상금을 계산한다. | ||
| - [x] 퍼센트 수익률을 계산한다. | ||
|
|
||
| ### Effect(부수효과 액션: 입출력/난수/에러) | ||
| - [x] `사용자의 입력 액션` | ||
| - [x] 로또 구입 금액을 읽는다. | ||
| - [x] 로또 당첨 번호를 읽는다. | ||
| - [x] 보너스 번호를 읽는다. | ||
| - [x] `에러 발생 액션` | ||
| - [x] 검증을 통과하지 못하면 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시킨다. | ||
| - [x] `사용자에게 재입력 액션` | ||
| - [x] 로또 구입 금액에 대한 입력값이 검증을 통과하지 못하면 해당 지점부터 다시 입력을 받는다. | ||
| - [x] 당첨 번호에 대한 입력값이 검증을 통과하지 못하면 해당 지점부터 다시 입력을 받는다. | ||
| - [x] 보너스 번호에 대한 입력값이 검증을 통과하지 못하면 해당 지점부터 다시 입력을 받는다. | ||
| - [x] `난수를 생성 액션` | ||
| - [x] 1 <= n <= 45 범위의 난수 n을 생성한다. | ||
| - [x] `로또 시뮬레이션 결과 출력 액션` | ||
| - [x] 발행한 로또의 수량과 번호을 출력한다. | ||
| - [x] 당첨 내역을 출력한다. | ||
| - [x] 수익률을 출력한다. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| import { MissionUtils } from "@woowacourse/mission-utils"; | ||
| import { createLottos, creatOneLotto } from "../src/domains/createLottoNumbers"; | ||
| import { randomUniquesInRange } from "../src/utils/random"; | ||
| import Lotto from "../src/entities/Lotto"; | ||
|
|
||
| const mockRandoms = (numbers) => { | ||
| MissionUtils.Random.pickUniqueNumbersInRange = jest.fn(); | ||
| numbers.reduce((acc, number) => { | ||
| return acc.mockReturnValueOnce(number); | ||
| }, MissionUtils.Random.pickUniqueNumbersInRange); | ||
| }; | ||
|
|
||
| describe("로또 번호 생성 테스트", () => { | ||
| test("1~45 사이의 중복되지 않는 숫자 6개를 반환한다.", () => { | ||
| const expectedLotto = [1, 2, 3, 4, 44, 45]; | ||
|
|
||
| mockRandoms([expectedLotto]); | ||
| const lottoInstance = creatOneLotto(randomUniquesInRange); | ||
|
|
||
| expect(lottoInstance).toBeInstanceOf(Lotto); | ||
| expect(lottoInstance.numbers).toEqual(expectedLotto); | ||
| }); | ||
| test("quantity만큼의 로또 세트를 반환한다..", () => { | ||
| const sample = [1, 2, 3, 4, 44, 45]; | ||
|
|
||
| mockRandoms([ sample, sample, sample, sample, sample, sample ]) // 6개 모킹 | ||
|
|
||
| // 5개만 생성 확인 | ||
| expect(createLottos(5, randomUniquesInRange).length).toBe(5); | ||
| }) | ||
| }) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 함수형으로 코드를 잘짜셔서 테스트가 수월하네요 !! |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| import { accumulateProfit, getRateOfInvestmentByPercent } from "../src/domains/profit"; | ||
| import { PRIZE_TABLE } from "../src/constants/lotto"; | ||
|
|
||
| describe("로또 수익 관련 로직 단위 테스트", () => { | ||
| test("1등, 4등을 했을 때 2,000,050,000을 반환한다.", () => { | ||
| const rankOflottos = [1, 4] | ||
| const profit = 2_000_050_000 // 1등(2억) + 4등(5만) | ||
| expect(accumulateProfit(rankOflottos, PRIZE_TABLE)).toBe(profit); | ||
| }); | ||
| test("6등을 했을 떄는 0을 반환한다.", () => { | ||
| const rank_6th = [6, 6, 6]; | ||
| const profit = 0; | ||
| expect(accumulateProfit(rank_6th, PRIZE_TABLE)).toBe(profit); | ||
| }); | ||
| test("퍼센트 환산된 수익률을 반환한다.", () => { | ||
| // 2000 / 1000 = 2 -> 200% | ||
| expect(getRateOfInvestmentByPercent(2000, 1000)).toBe(200); | ||
| // 995 / 1000 = 0.995 -> 99.5% | ||
| expect(getRateOfInvestmentByPercent(995, 1000)).toBe(99.5); | ||
| }); | ||
| test("소수점 이하의 결과를 정확히 반환한다.", () => { | ||
| // 3333 / 7000 = 0.47614... -> 47.614..% | ||
| const expectAboutResult = 47.614 | ||
| expect(getRateOfInvestmentByPercent(3333, 7000)).toBeCloseTo(expectAboutResult); | ||
| }); | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| import { calculateMatchCount, isBonusMatch, determineRankOf } from "../src/domains/ranking"; | ||
| import { RANK_TABLE } from "../src/constants/lotto"; | ||
|
|
||
| describe("순위 결정 관련 비지니스 로직 단위 테스트", () => { | ||
| test("일치하는 개수를 반환한다.", () => { | ||
| const ticket = [1, 2, 3, 4, 5, 6]; | ||
| const winning = [1, 2, 3, 4, 5, 6]; | ||
| expect(calculateMatchCount(ticket, winning)).toBe(6); | ||
| }); | ||
| test("하나도 일치하지 않을 경우 0을 반환한다.", () => { | ||
| const ticket = [1, 2, 3, 4, 5, 6]; | ||
| const winning = [7, 8, 9, 10, 11, 12]; | ||
| expect(calculateMatchCount(ticket, winning)).toBe(0); | ||
| }); | ||
| test("보너스 넘버를 포함하고 있다면 true를 반환한다.", () => { | ||
| const ticket = [1, 2, 3, 4, 5, 6]; | ||
| const bonusInclude = 5 | ||
| expect(isBonusMatch(ticket, bonusInclude)).toBe(true); | ||
| }); | ||
| test("보너스 넘버를 포함하고 있지않다면 false를 반환한다.", () => { | ||
| const ticket = [1, 2, 3, 4, 5, 6]; | ||
| const bonusNotInclude = 45; | ||
| expect(isBonusMatch(ticket, bonusNotInclude)).toBe(false); | ||
| }); | ||
| test("일치하는 번호의 수가 5개이고 보너스 번호가 일치하면 2등이다.", () => { | ||
| const matchedCnt = 5; | ||
| const isBonusMatch = true; | ||
| expect(determineRankOf(matchedCnt, isBonusMatch, RANK_TABLE)).toBe(2); | ||
| }) | ||
| test("일치하는 번호의 수가 5개이고 보너스 번호가 일치하지 않으면 3등이다.", () => { | ||
| const matchedCnt = 5; | ||
| const isBonusMatch = false; | ||
| expect(determineRankOf(matchedCnt, isBonusMatch, RANK_TABLE)).toBe(3); | ||
| }) | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| import { toNumber, parseToArrayByComma, devisionNumber} from "../src/utils/parsing"; | ||
|
|
||
| describe("파싱 유틸 검증", () => { | ||
| test("숫자로 이루어진 문자열을 숫자 타입으로 변환한다.", () => { | ||
| expect(toNumber("123")).toBe(123); | ||
| }); | ||
| test("쉼표를 포함하는 문자열을 쉼표로 나누어 숫자 배열로 변환한다.", () => { | ||
| expect(parseToArrayByComma("1,2,3")).toStrictEqual([1, 2, 3]); | ||
| }); | ||
| test("구입 금액을 단위(1000)으로 나누어 티켓 장수를 반환하다.", () => { | ||
| expect(devisionNumber(5000, 1000)).toBe(5); | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| import { validateLottoNumbers, validateBonusNumber, validateCost } from "../src/domains/validate"; | ||
| import { ERROR_MSG } from "../src/constants/lotto"; | ||
|
|
||
| describe("당첨 번호 도메인 검증", () => { | ||
| test("로또 번호는 6개이어야 한다.", () => { | ||
| expect(validateLottoNumbers([1, 2, 3, 4, 5])).toBe(ERROR_MSG.LOTTO_SIZE); | ||
| }); | ||
| test("로또 번호는 정수이어야 한다.", () => { | ||
| expect(validateLottoNumbers(["m", 1, 2, 3, 4, 5])).toBe(ERROR_MSG.NUMBER_INTEGER); | ||
| expect(validateLottoNumbers([1.1, 1, 2, 3, 4, 5])).toBe(ERROR_MSG.NUMBER_INTEGER); | ||
| }) | ||
| test("각 로또 번호는 1~45 범위 안에 있어야 한다.", () => { | ||
| expect(validateLottoNumbers([0, 1, 2, 3, 4, 5])).toBe(ERROR_MSG.LOTTO_NUM_RANGE); // < 1 | ||
| expect(validateLottoNumbers([1, 2, 3, 4, 5, 46])).toBe(ERROR_MSG.LOTTO_NUM_RANGE); // > 45 | ||
| }); | ||
| test("로또 번호는 중복될 수 없다.", () => { | ||
| expect(validateLottoNumbers([1, 2, 3, 4, 5, 5])).toBe(ERROR_MSG.LOTTO_NUM_UNIQUE); | ||
| }); | ||
| test("정상적인 로또 번호는 true를 반환한다..", () => { | ||
| expect(validateLottoNumbers([1, 2, 3, 4, 5, 45])).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| describe("보너스 번호 도메인 검증", () => { | ||
| test("보너스 번호는 1~45 범위 안에 있어야 한다.", () => { | ||
| expect(validateBonusNumber(46)).toBe(ERROR_MSG.LOTTO_NUM_RANGE); | ||
| }); | ||
| test("보너스 번호는 로또 번호와 중복될 수 없다.", () => { | ||
| expect(validateBonusNumber(4, [1, 2, 3, 4, 5, 6])).toBe(ERROR_MSG.LOTTO_NUM_UNIQUE); | ||
| }); | ||
| test("정상적인 보너스 번호는 true를 반환한다.", () => { | ||
| expect(validateBonusNumber(25, [1, 2, 3, 4, 5, 6])).toBe(true); | ||
| }) | ||
| }); | ||
|
|
||
| describe("구입 금액 도메인 검증", () => { | ||
| test("구입 금액은 단위가 1000이어야 한다.", () => { | ||
| expect(validateCost(2500)).toBe(ERROR_MSG.COST_UNIT); | ||
| }); | ||
| test("정상적인 구입 금액은 true를 반환한다.", () => { | ||
| expect(validateCost(2000)).toBe(true); | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,38 @@ | ||
| import { LABELS, OUTPUT_MSG } from "./constants/ioMsg"; | ||
| import { LOTTO_CONSTANTS, PRIZE_TABLE, RANK_TABLE } from "./constants/lotto"; | ||
| import { readBonusNumberUntilValid, readPurchasedAmountUntilValid, readWinningNumbersUntilValid } from "./view/input"; | ||
| import { devisionNumber } from "./utils/parsing"; | ||
| import { createLottos } from "./domains/createLottoNumbers"; | ||
| import { MissionUtils } from "@woowacourse/mission-utils"; | ||
| import { randomUniquesInRange } from "./utils/random"; | ||
| import { calculateMatchCount, determineRankOf, getResultOfLotto, isBonusMatch } from "./domains/ranking"; | ||
| import { accumulateProfit, getRateOfInvestmentByPercent } from "./domains/profit"; | ||
| import { printGeneratedLottos, printResultStats } from "./view/output"; | ||
|
|
||
| class App { | ||
| async run() {} | ||
| async run() { | ||
| const purchasedAmount = await readPurchasedAmountUntilValid(); | ||
| const ticketAmount = devisionNumber(purchasedAmount, LOTTO_CONSTANTS.TICKET_PRICE); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. devisionNumber 함수를 쓰신 이유가 있을까요 ? |
||
|
|
||
| const lottos = createLottos(ticketAmount, randomUniquesInRange); // 난수 생성 함수를 주입 | ||
|
|
||
| printGeneratedLottos(ticketAmount, lottos); | ||
|
|
||
| const winningNums = await readWinningNumbersUntilValid(); | ||
| const bonusNum = await readBonusNumberUntilValid(winningNums); | ||
|
|
||
| const results = getResultOfLotto(lottos, winningNums, bonusNum); | ||
| const rankResultArr = results.map(({rank}) => rank); | ||
| const totalProfit = accumulateProfit(rankResultArr, PRIZE_TABLE); | ||
| const rateOfInvestment = getRateOfInvestmentByPercent(totalProfit, purchasedAmount); // | ||
|
|
||
| const rankCounts = results.reduce((acc, { rank }) => { | ||
| if (rank) acc[rank] = (acc[rank] || 0) + 1; | ||
| return acc; | ||
| }, {}); | ||
|
|
||
| printResultStats(rankCounts, rateOfInvestment); | ||
| } | ||
| } | ||
|
|
||
| export default App; | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| export const INPUT_QUESTION = Object.freeze({ | ||
| COST: "구입금액을 입력해 주세요.", | ||
| WINNING_NUMS: "당첨 번호를 입력해 주세요.", | ||
| BONUS_NUM: "보너스 번호를 입력해 주세요.", | ||
| }); | ||
|
|
||
| export const OUTPUT_MSG = Object.freeze({ | ||
| PURCHASED_TICKETS: (count) => `${count}개를 구매했습니다.`, | ||
| WINNING_STATS_HEADER: "\n당첨 통계", | ||
| WINNING_STATS_DIVIDER: "---", | ||
| ROI_RESULT: (roi) => `총 수익률은 ${roi}%입니다.`, | ||
| }); | ||
|
|
||
| export const LABELS = { | ||
| 5: "3개 일치", | ||
| 4: "4개 일치", | ||
| 3: "5개 일치", | ||
| 2: "5개 일치, 보너스 볼 일치", | ||
| 1: "6개 일치", | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| export const LOTTO_CONSTANTS = Object.freeze({ | ||
| TICKET_PRICE: 1000, | ||
| MIN_NUMBER: 1, | ||
| MAX_NUMBER: 45, | ||
| NUMBERS_PER_TICKET: 6, | ||
| }); | ||
|
|
||
| export const RANK_TABLE = Object.freeze({ // 일치하는 숫자 개수 : { bonusTrue : 랭크, bonusFalse: 랭크} | ||
| 6: Object.freeze({true: 1, false: 2,}), | ||
| 5: Object.freeze({true: 2, false: 3,}), | ||
| 4: Object.freeze({true: 4, false: 4,}), | ||
| 3: Object.freeze({true: 5, false: 5,}), | ||
| 2: Object.freeze({true: 6, false: 6,}), | ||
| 1: Object.freeze({true: 6, false: 6,}), | ||
| 0: Object.freeze({true: 6, false: 6,}), | ||
| }); | ||
|
|
||
| export const PRIZE_TABLE = Object.freeze({ | ||
| 1: 2_000_000_000, | ||
| 2: 30_000_000, // 5개 일치 + 보너스 번호 일치 | ||
| 3: 1_500_000, | ||
| 4: 50_000, | ||
| 5: 5_000, | ||
| 6: 0, | ||
| }); | ||
|
|
||
| export const ERROR_MSG = Object.freeze({ | ||
| COST_UNIT: "[ERROR] 구입 금액은 1000원 단위의 숫자여야 합니다.\n", | ||
| LOTTO_SIZE: "[ERROR] 로또 번호는 6개여야 합니다.\n", | ||
| LOTTO_NUM_RANGE: "[ERROR] 로또 번호는 1~45만으로 이루어집니다.\n", | ||
| LOTTO_NUM_UNIQUE: "[ERROR] 하나의 로또에 중복된 숫자가 존재할 수 없습니다.\n", | ||
| NUMBER_INTEGER: "[ERROR] 로또 번호는 정수입니다.\n" | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| import { LOTTO_CONSTANTS } from "../constants/lotto" | ||
| import Lotto from "../entities/Lotto"; | ||
|
|
||
| export function creatOneLotto(drawUniqueNumbers) { | ||
| const { MIN_NUMBER, MAX_NUMBER, NUMBERS_PER_TICKET } = LOTTO_CONSTANTS | ||
| const numbers = drawUniqueNumbers( MIN_NUMBER, MAX_NUMBER, NUMBERS_PER_TICKET ); | ||
| return new Lotto(numbers); | ||
| }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 코드 detph가 너무 깊은것 같습니다!! 여기에서 선언안하시고 상위에서 주입하신 이유가 있을까요 ? 랜덤 함수가 추후에 바뀌는것을 대비해서 랜덤 모듈을 손쉽게 갈아끼기 위함 입니까? |
||
|
|
||
| export function createLottos(quantity, drawUniqueNumbers) { | ||
| return Array.from({ length: quantity }, () => | ||
| creatOneLotto(drawUniqueNumbers) | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { LOTTO_CONSTANTS, ERROR_MSG } from "../constants/lotto" | ||
| import { toArray, includesNumber, isIntegerValue } from "../utils"; | ||
|
|
||
| // 로또의 비지니스 규칙과 관련된 검증들 | ||
| export const isValidPurchaseAmount = (amount) => | ||
| amount % LOTTO_CONSTANTS.TICKET_PRICE === 0 || ERROR_MSG.COST_UNIT; | ||
|
|
||
| export const hasExactSize = (arr) => | ||
| arr.length === LOTTO_CONSTANTS.NUMBERS_PER_TICKET || ERROR_MSG.LOTTO_SIZE | ||
|
|
||
| export const inRange = (valueOrArr) => { | ||
| const arr = toArray(valueOrArr); | ||
| return arr.every( // every: 함수형, 즉시 종료 | ||
| n => | ||
| n >= LOTTO_CONSTANTS.MIN_NUMBER && | ||
| n <= LOTTO_CONSTANTS.MAX_NUMBER | ||
| ) || ERROR_MSG.LOTTO_NUM_RANGE; | ||
| } | ||
|
|
||
| export const isIntegerArr = (valueOrArr) => { | ||
| const arr = toArray(valueOrArr); | ||
| return arr.every( | ||
| n => isIntegerValue(n) | ||
| ) || ERROR_MSG.NUMBER_INTEGER; | ||
| } | ||
|
|
||
| export const isLottoNumUnique = (arr) => | ||
| new Set(arr).size === arr.length || ERROR_MSG.LOTTO_NUM_UNIQUE; | ||
|
|
||
| export const isBonusUnique = (num, arr) => | ||
| !includesNumber(arr, num) || ERROR_MSG.LOTTO_NUM_UNIQUE; | ||
|
|
||
| export const costRules = [isIntegerArr, isValidPurchaseAmount]; | ||
| export const lottoRules = [hasExactSize, isIntegerArr, inRange, isLottoNumUnique]; | ||
| export const bonusRules = [isIntegerArr, inRange, isBonusUnique]; | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| export function accumulateProfit(rankArr, PRIZE_TABLE) { | ||
| const totalProfit = rankArr.reduce( | ||
| (profit, rank) => profit + PRIZE_TABLE[rank], | ||
| 0 | ||
| ); | ||
| return totalProfit; | ||
| }; | ||
|
|
||
| // 출력 요구사항에 "수익률은 소수점 둘째 자리에서 반올림한다"를 통해 반올림만 명시 | ||
| // -> 퍼센트 변환는 계산, 반올림은 표현의 영역이라고 판단 | ||
| export function getRateOfInvestmentByPercent(totalProfit, investment) { | ||
| const ratio = totalProfit / investment; | ||
| const ratioByPercent = ratio * 100; | ||
| return ratioByPercent; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
리드미 체크리스트 덕분에 구현사항을 한눈에 볼수 있어 좋네요!