Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ac70aed
docs(readme): 기능목록 작성
Nov 2, 2025
5b738c5
docs(readme): 기능 목록을 effect와 pure 기준으로 재구성
Nov 2, 2025
d002db0
feat(constants): 로또 상수 추가
Nov 2, 2025
602187b
feat(ranking): calculate matched numbers
Nov 2, 2025
4df3b3a
feat(domain): check lotto includes bonus number
Nov 3, 2025
467beb0
style: apply blank line after imports across files
Nov 3, 2025
91d5617
feat(domain): compute lotto rank based on match count and bonus flag
Nov 3, 2025
e252496
feat(domain): accumulate profit of lotto based on rank
Nov 3, 2025
2f25d74
feat(domain): implement ROI percent calculation
Nov 3, 2025
8827cc3
test: seperate to specific domain test files
Nov 3, 2025
15dc33e
feat(domain): implement validate lotto number in range
Nov 3, 2025
c07e8c4
feat(domain): implement validate lotto num is unique
Nov 3, 2025
03fba68
feat(domain): implement validate lotto size
Nov 3, 2025
10be6a8
feat(domain): implement validate bonus number
Nov 3, 2025
6baad1f
feat(domain): implement validate cost
Nov 3, 2025
3b9c13b
feat(util): implement number string parsing function
Nov 3, 2025
21af86c
feat(util): implement divison function
Nov 3, 2025
99bab77
feat(domain): implement create lotto number
Nov 3, 2025
28bdf34
feat(domain): implement create lotto numbers based on tickets quantity
Nov 3, 2025
b2e5d21
feat(entity): implement and create Lotto Class
Nov 3, 2025
b3cf446
feat(app): implement read until valid function
Nov 3, 2025
219fd71
feat(domain): implement validate isInteger
Nov 3, 2025
04163be
feat(app): print purchased amount and generated numbers
Nov 3, 2025
0295c78
feat(app): print lotto result statistics
Nov 3, 2025
d2d5566
feat(app): print rate of investment
Nov 3, 2025
e7df964
refactor(app): make output massages to constant object
Nov 3, 2025
df46dd1
refactor(view): seperate input function to view
Nov 3, 2025
15fbced
refactor(view): make print view and seperate app.run()
Nov 3, 2025
a1cb95b
docs(readme): check feature list
Nov 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
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] 수익률을 출력한다.
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

리드미 체크리스트 덕분에 구현사항을 한눈에 볼수 있어 좋네요!

31 changes: 31 additions & 0 deletions __tests__/CreatLottoDomain.test.js
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);
})
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

함수형으로 코드를 잘짜셔서 테스트가 수월하네요 !!

2 changes: 1 addition & 1 deletion __tests__/LottoTest.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import Lotto from "../src/Lotto";
import Lotto from "../src/entities/Lotto";

describe("로또 클래스 테스트", () => {
test("로또 번호의 개수가 6개가 넘어가면 예외가 발생한다.", () => {
Expand Down
26 changes: 26 additions & 0 deletions __tests__/ProfitDomainUnit.test.js
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);
});
})
35 changes: 35 additions & 0 deletions __tests__/RankingDomainUnit.test.js
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);
})
});
13 changes: 13 additions & 0 deletions __tests__/UtilUnit.test.js
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);
})
})
43 changes: 43 additions & 0 deletions __tests__/ValidateDomainUnit.test.js
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);
})
})
35 changes: 34 additions & 1 deletion src/App.js
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);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

devisionNumber 함수를 쓰신 이유가 있을까요 ?
purchasedAmount / LOTTO_CONSTANTS.TICKET_PRICE
가 좀더 깔끔해 보이는것 같기도 하고요 ...
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;
18 changes: 0 additions & 18 deletions src/Lotto.js

This file was deleted.

20 changes: 20 additions & 0 deletions src/constants/ioMsg.js
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개 일치",
};
33 changes: 33 additions & 0 deletions src/constants/lotto.js
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"
})
14 changes: 14 additions & 0 deletions src/domains/createLottoNumbers.js
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);
};
Copy link

Choose a reason for hiding this comment

The 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)
);
};
36 changes: 36 additions & 0 deletions src/domains/lottoRules.js
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];

15 changes: 15 additions & 0 deletions src/domains/profit.js
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;
}
Loading