diff --git a/__tests__/lotto.test.js b/__tests__/lotto.test.js new file mode 100644 index 000000000..206c6caec --- /dev/null +++ b/__tests__/lotto.test.js @@ -0,0 +1,49 @@ +import { generateLottoNumber, getTicket } from '../src/lotto.js' +import { LottoError } from '../src/errors/lottoError.js' + +describe('getTicket 함수 테스트', () => { + it('1,000원을 입력하면 1장을 발급한다', () => { + const money = 1000 + + expect(getTicket(money)).toHaveLength(1) + }) + + it('1,000원 단위로 티켓을 발급하며, 2,500원을 입력하면 2장을 발급한다', () => { + const money = 2500 + + expect(getTicket(money)).toHaveLength(2) + }) + + it('1,000원 미만은 에러를 발생시킨다', () => { + const money = 900 + + expect(() => getTicket(money)).toThrow(LottoError.TicketPriceTooLow()) + }) + + it('금액을 입력하지 않으면 에러를 발생시킨다', () => { + expect(() => getTicket()).toThrow(LottoError.TicketPriceTooLow()) + }) +}) + +describe('generateLottoNumber 함수 테스트', () => { + it('6개의 숫자로 구성된다', () => { + const numbers = generateLottoNumber() + + expect(numbers).toHaveLength(6) + }) + + it('6개의 숫자는 중복되지 않는다', () => { + const numbers = new Set(generateLottoNumber()) + + expect(numbers.size).toBe(6) + }) + + it('1~45 범위의 숫자들로 구성된다', () => { + const numbers = generateLottoNumber() + + numbers.forEach(number => { + expect(number).toBeGreaterThanOrEqual(1) + expect(number).toBeLessThanOrEqual(45) + }) + }) +}) \ No newline at end of file diff --git a/__tests__/lottoGame.test.js b/__tests__/lottoGame.test.js new file mode 100644 index 000000000..be76f11de --- /dev/null +++ b/__tests__/lottoGame.test.js @@ -0,0 +1,55 @@ +import { calculateProfitRate, getRank } from '../src/lottoGame.js' + +describe('getRank 함수 테스트', () => { + let winningNumbers + let winningBonusNumber + + beforeEach(() => { + winningNumbers = [1, 2, 3, 4, 5, 6] + winningBonusNumber = 7 + }) + + it('숫자 3개가 일치하면 5,000원을 받는다', () => { + const ticketNumbers = [1, 2, 3, 14, 15, 16] + const rank = getRank(ticketNumbers, winningNumbers, winningBonusNumber) + + expect(rank.prize).toBe(5_000) + }) + + it('보너스 번호 제외 숫자 5개가 일치하면 1,500,000원을 받는다', () => { + const ticketNumbers = [1, 2, 3, 4, 5, 16] + const rank = getRank(ticketNumbers, winningNumbers, winningBonusNumber) + + expect(rank.prize).toBe(1_500_000) + }) + + it('보너스 번호 포함 숫자 6개가 일치하면 30,000,000원을 받는다', () => { + const ticketNumbers = [1, 2, 3, 4, 5, 7] + const rank = getRank(ticketNumbers, winningNumbers, winningBonusNumber) + + expect(rank.prize).toBe(30_000_000) + }) + + it('숫자 6개가 전부 일치하면 2,000,000,000원을 받는다', () => { + const ticketNumbers = [1, 2, 3, 4, 5, 6] + const rank = getRank(ticketNumbers, winningNumbers, winningBonusNumber) + + expect(rank.prize).toBe(2_000_000_000) + }) +}) + +describe('calculateProfitRate 함수 테스트', () => { + it('구매 금액과 당첨금으로 총 수익률을 계산한다', () => { + const purchaseAmount = 10_000 + const totalWinnings = 50_000 + + expect(calculateProfitRate(purchaseAmount, totalWinnings)).toBe(500) + }) + + it('수익률은 소수점 셋째 자리에서 반올림하여 둘째 자리까지만 유지한다', () => { + const purchaseAmount = 30_000 + const totalWinnings = 5_000 + + expect(calculateProfitRate(purchaseAmount, totalWinnings)).toBe(16.67) + }) +}) \ No newline at end of file diff --git a/src/docs/REQUIREMENTS.md b/src/docs/REQUIREMENTS.md new file mode 100644 index 000000000..7aecd8fbd --- /dev/null +++ b/src/docs/REQUIREMENTS.md @@ -0,0 +1,28 @@ +# 기능 요구 사항 + +## 1단계 - 콘솔 기반 로또 게임 + +### 입출력 +-[x] 사용자로부터 구입 금액을 입력받는다. +-[x] 사용자로부터 당첨 번호(6개)와 보너스 번호(1개)를 입력받는다. +-[x] 발행된 로또 번호들을 출력한다. +-[x] 등수별 당첨 개수를 출력한다. + - `3개 일치 (5,000원) - 1개` 의 형식으로 출력한다. +-[x] 총 수익률을 출력한다. + +### 로또 +-[x] 로또 1장의 가격은 1,000원이다. +-[x] 구입 금액에 따라 구입 금액에 해당하는 만큼 로또를 발행해야 한다. +-[x] 로또 1장은 1~45 범위의 중복되지 않은 6개의 숫자로 구성된다. + +### 로또 게임 +-[x] 입력받은 당첨 번호와 보너스 번호를 검증한다. +-[x] 사용자가 구매한 로또 번호와 당첨 번호를 비교해 등수를 판정한다. +-[x] 당첨 기준 및 상금은 다음과 같다: + - 1등: 6개 번호 일치 / 2,000,000,000원 + - 2등: 5개 번호 + 보너스 번호 일치 / 30,000,000원 + - 3등: 5개 번호 일치 / 1,500,000원 + - 4등: 4개 번호 일치 / 50,000원 + - 5등: 3개 번호 일치 / 5,000원 +-[x] 총 수익률을 계산한다. (총 당첨금 ÷ 구입 금액 * 100) + - 수익률은 소수점 셋째 자리에서 반올림하여 둘째 자리까지만 유지한다. diff --git a/src/errors/lottoError.js b/src/errors/lottoError.js new file mode 100644 index 000000000..624511638 --- /dev/null +++ b/src/errors/lottoError.js @@ -0,0 +1,7 @@ +function ticketPriceTooLow() { + return new Error('구매 금액은 최소 1,000원 이상이어야 합니다.') +} + +export const LottoError = { + TicketPriceTooLow: ticketPriceTooLow, +} \ No newline at end of file diff --git a/src/lotto.js b/src/lotto.js new file mode 100644 index 000000000..7a5a9af4b --- /dev/null +++ b/src/lotto.js @@ -0,0 +1,24 @@ +import { LottoError } from './errors/lottoError.js' + +export const LOTTO_PRICE = 1000 +const LOTTO_NUMBERS_LENGTH = 6 + +export function getTicket(money) { + if (!money || money < LOTTO_PRICE) { + throw LottoError.TicketPriceTooLow() + } + + const ticketCount = Math.floor(money / LOTTO_PRICE) + return Array.from({ length: ticketCount }, () => generateLottoNumber()) +} + +export function generateLottoNumber() { + const numbers = new Set() + + while (numbers.size < LOTTO_NUMBERS_LENGTH) { + const num = Math.floor(Math.random() * 45) + 1 + numbers.add(num) + } + + return Array.from(numbers) +} \ No newline at end of file diff --git a/src/lottoGame.js b/src/lottoGame.js new file mode 100644 index 000000000..735ffe2a0 --- /dev/null +++ b/src/lottoGame.js @@ -0,0 +1,47 @@ +const LOTTO_RANK_TABLE = [ + { rank: 1, matchCount: 6, prize: 2_000_000_000 }, + { rank: 2, matchCount: 6, prize: 30_000_000, bonus: true }, + { rank: 3, matchCount: 5, prize: 1_500_000 }, + { rank: 4, matchCount: 4, prize: 50_000 }, + { rank: 5, matchCount: 3, prize: 5_000 }, +] + +export function getRank(ticketNumbers, winningNumbers, winningBonusNumber) { + const matchCount = ticketNumbers.filter(num => winningNumbers.includes(num)).length + const hasBonusNumber = ticketNumbers.includes(winningBonusNumber) + + return LOTTO_RANK_TABLE.find(rank => { + if (rank.bonus) { + return hasBonusNumber && rank.matchCount === matchCount + } + + return rank.matchCount === matchCount + }) +} + +export function calculateProfitRate(purchaseAmount, totalWinnings) { + const rate = (totalWinnings / purchaseAmount) * 100 + + return Math.round(rate * 100) / 100 +} + +export function getRankStatistics(results) { + let totalWinnings = 0 + const stats = LOTTO_RANK_TABLE.map(rank => ({ + ...rank, + count: 0, + })) + + results.forEach(result => { + totalWinnings += result.prize + const rankIndex = stats.findIndex(rank => + rank.matchCount === result.matchCount && rank.bonus === result.bonus + ) + + if (rankIndex !== -1) { + stats[rankIndex].count += 1 + } + }) + + return { stats: stats.reverse(), totalWinnings } +} diff --git a/src/lottoPrint.js b/src/lottoPrint.js new file mode 100644 index 000000000..4ce8cb80a --- /dev/null +++ b/src/lottoPrint.js @@ -0,0 +1,22 @@ +export function printTicket(tickets) { + console.log(`${tickets.length}개를 구매했습니다.`) + tickets.forEach(ticket => console.log(ticket)) +} + +export function printResults(stats, totalWinnings) { + if (!totalWinnings) { + console.log('낙첨되었습니다.') + return + } + + console.log('당첨 통계') + console.log('--------------------') + + stats.forEach(({ matchCount, prize, bonus, count }) => { + console.log(`${matchCount}개 일치${bonus ? ', 보너스 볼 일치': ''} (${prize.toLocaleString()}원) - ${count.toLocaleString()}개`) + }) +} + +export function printProfit(profitRate) { + console.log(`총 수익률은 ${profitRate.toLocaleString()}% 입니다.`) +} \ No newline at end of file diff --git a/src/step1-index.js b/src/step1-index.js index 44313b450..7420aef72 100644 --- a/src/step1-index.js +++ b/src/step1-index.js @@ -1,4 +1,37 @@ -/** - * step 1의 시작점이 되는 파일입니다. - * 브라우저 환경에서 사용하는 css 파일 등을 불러올 경우 정상적으로 빌드할 수 없습니다. - */ +import { readLineAsync } from './utils/readlineAsync.js' +import { getTicket, LOTTO_PRICE } from './lotto.js' +import { calculateProfitRate, getRank, getRankStatistics } from './lottoGame.js' +import { printProfit, printResults, printTicket } from './lottoPrint.js' + +async function start() { + const tickets = await getTickets() + printTicket(tickets) + + const { winningNumbers, winningBonusNumber } = await getWinningNumber() + const { stats, totalWinnings } = getStatistics(tickets, winningNumbers, winningBonusNumber) + printResults(stats, totalWinnings) + + const profitRate = calculateProfitRate(tickets.length * LOTTO_PRICE, totalWinnings) + printProfit(profitRate) +} + +async function getTickets() { + const purchaseAmount = await readLineAsync('구입 금액을 입력해 주세요. ') + return getTicket(purchaseAmount) +} + +async function getWinningNumber() { + let winningNumbers = await readLineAsync('당첨 번호를 입력해 주세요. ') + winningNumbers = winningNumbers.split(',').map(num => Number(num.trim())) + + const winningBonusNumber = await readLineAsync('보너스 번호를 입력해 주세요. ') + + return { winningNumbers, winningBonusNumber: Number(winningBonusNumber) } +} + +function getStatistics(tickets, winningNumbers, winningBonusNumber) { + const results = tickets.map(ticket => getRank(ticket, winningNumbers, winningBonusNumber)).filter(Boolean) + return getRankStatistics(results) +} + +await start() \ No newline at end of file diff --git a/src/utils/readlineAsync.js b/src/utils/readlineAsync.js new file mode 100644 index 000000000..69f55c940 --- /dev/null +++ b/src/utils/readlineAsync.js @@ -0,0 +1,23 @@ +import readline from 'readline'; + +export function readLineAsync(query) { + return new Promise((resolve, reject) => { + if (arguments.length !== 1) { + reject(new Error('arguments must be 1')); + } + + if (typeof query !== 'string') { + reject(new Error('query must be string')); + } + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + + rl.question(query, (input) => { + rl.close(); + resolve(input); + }); + }); +} \ No newline at end of file