diff --git a/README.md b/README.md index bab3552..d33becb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 자동차 경주 미션 +# 로또 미션 ## 참고링크 및 저장소 diff --git a/src/__tests__/lotto.test.js b/src/__tests__/lotto.test.js new file mode 100644 index 0000000..4b64e23 --- /dev/null +++ b/src/__tests__/lotto.test.js @@ -0,0 +1,94 @@ +import Lotto from '../domain/Lotto.js'; +import { lottoValidations } from '../validations/lotto.js'; +import LOTTO_TYPE from '../constants/lottoType.js'; + +describe('로또 테스트', () => { + test('로또 번호는 [당첨, 티켓] 두가지 유형을 가질 수 있다.', () => { + expect( + () => + new Lotto({ + type: 'otherType', + numbers: [1, 2, 3, 4, 5, 6], + }), + ).toThrow(lottoValidations.lottoType.errorMessage); + }); + + test('로또 번호는 6개여야 한다.', () => { + expect( + () => + new Lotto({ + type: LOTTO_TYPE.TICKET, + numbers: [1, 2, 3, 4, 5], + }), + ).toThrow(lottoValidations.lottoNumbersLength.errorMessage); + }); + + test('당첨 로또 번호는 보너스 번호를 가져야 한다.', () => { + expect( + () => + new Lotto({ + type: LOTTO_TYPE.WINNING, + numbers: [1, 2, 3, 4, 5, 6], + }), + ).toThrow(lottoValidations.winningLottoHasBonus.errorMessage); + }); + + test('티켓 로또 번호는 보너스 번호가 없어야 한다.', () => { + expect( + () => + new Lotto({ + type: LOTTO_TYPE.TICKET, + numbers: [1, 2, 3, 4, 5, 6], + bonusNumber: 7, + }), + ).toThrow(lottoValidations.ticketLottoBonusNull.errorMessage); + }); + + test('로또 번호는 각각 달라야 한다.', () => { + expect( + () => + new Lotto({ + type: LOTTO_TYPE.WINNING, + numbers: [1, 2, 3, 4, 5, 6], + bonusNumber: 6, + }), + ).toThrow(lottoValidations.lottoEachUnique.errorMessage); + + expect( + () => + new Lotto({ + type: LOTTO_TYPE.TICKET, + numbers: [1, 2, 3, 3, 5, 6], + }), + ).toThrow(lottoValidations.lottoEachUnique.errorMessage); + }); + + test('모든 로또 번호는 정수여야 한다.', () => { + expect( + () => + new Lotto({ + type: LOTTO_TYPE.TICKET, + numbers: [1, 2, 3, 4, 5, '6'], + }), + ).toThrow(lottoValidations.lottoInteger.errorMessage); + }); + + test('모든 로또 번호는 1 이상 45 이하여야 한다.', () => { + expect( + () => + new Lotto({ + type: LOTTO_TYPE.WINNING, + numbers: [1, 2, 3, 4, 5, 45], + bonusNumber: 46, + }), + ).toThrow(lottoValidations.lottoRange.errorMessage); + + expect( + () => + new Lotto({ + type: LOTTO_TYPE.TICKET, + numbers: [0, 1, 2, 3, 4, 5], + }), + ).toThrow(lottoValidations.lottoRange.errorMessage); + }); +}); diff --git a/src/__tests__/lottoMatcher.test.js b/src/__tests__/lottoMatcher.test.js new file mode 100644 index 0000000..9472666 --- /dev/null +++ b/src/__tests__/lottoMatcher.test.js @@ -0,0 +1,72 @@ +import { createLottoTicket, createWinningLotto } from '../domain/Lotto.js'; +import LottoMatcher from '../domain/LottoMatcher.js'; +import { lottoMatcherValidations } from '../validations/lottoMatcher.js'; +import LOTTO_TYPE from '../constants/lottoType.js'; + +describe('로또 번호 일치여부 계산 테스트', () => { + test('유효한 당첨 로또로만 계산할 수 있다.', () => { + const validLottoTicket = createLottoTicket([11, 12, 13, 14, 15, 16]); + + expect(() => + LottoMatcher.matchLotto({ + winningLotto: { type: LOTTO_TYPE.WINNING, numbers: [1, 2, 3, 4, 5, 6], bonusNumber: 8 }, + lottoTickets: [validLottoTicket], + }), + ).toThrow(lottoMatcherValidations.validWinningLotto.errorMessage); + + expect(() => + LottoMatcher.matchLotto({ + winningLotto: validLottoTicket, + lottoTickets: [validLottoTicket], + }), + ).toThrow(lottoMatcherValidations.validWinningLotto.errorMessage); + }); + + test('유효한 로또 티켓들만 계산할 수 있다.', () => { + const validWinningLotto = createWinningLotto([1, 2, 3, 4, 5, 6], 7); + + expect(() => + LottoMatcher.matchLotto({ + winningLotto: validWinningLotto, + lottoTickets: [{ type: LOTTO_TYPE.TICKET, numbers: [1, 2, 3, 4, 5, 6] }], + }), + ); + + expect(() => + LottoMatcher.matchLotto({ + winningLotto: validWinningLotto, + lottoTickets: [validWinningLotto], + }), + ).toThrow(lottoMatcherValidations.validLottoTickets.errorMessage); + }); + + test('로또 티켓들의 [당첨 번호 일치 개수, 보너스 번호 일치 여부]를 반환한다.', () => { + const winningLotto = createWinningLotto([1, 2, 3, 4, 5, 6], 7); + const lottoTicket1 = createLottoTicket([1, 2, 3, 14, 15, 16]); // 3, false + const lottoTicket2 = createLottoTicket([11, 12, 13, 14, 15, 16]); // 0, false + const lottoTicket3 = createLottoTicket([1, 2, 3, 4, 7, 8]); // 4, true + + expect( + LottoMatcher.matchLotto({ + winningLotto, + lottoTickets: [lottoTicket1, lottoTicket2, lottoTicket3], + }), + ).toEqual([ + { + lotto: lottoTicket1, + matchCount: 3, + bonusMatch: false, + }, + { + lotto: lottoTicket2, + matchCount: 0, + bonusMatch: false, + }, + { + lotto: lottoTicket3, + matchCount: 4, + bonusMatch: true, + }, + ]); + }); +}); diff --git a/src/__tests__/lottoPayment.test.js b/src/__tests__/lottoPayment.test.js new file mode 100644 index 0000000..f4f695b --- /dev/null +++ b/src/__tests__/lottoPayment.test.js @@ -0,0 +1,27 @@ +import LottoPayment from '../domain/LottoPayment.js'; +import { lottoPaymentValidations } from '../validations/lottoPayment.js'; + +describe('로또 결제 테스트', () => { + test('로또 결제 금액은 정수여야 한다.', () => { + expect(() => + LottoPayment.createLottoTickets({ + payAmount: '1000', + }), + ).toThrow(lottoPaymentValidations.payAmountInteger.errorMessage); + }); + + test('로또 결제는 1000원 단위로만 가능하다.', () => { + expect(() => + LottoPayment.createLottoTickets({ + payAmount: 1100, + }), + ).toThrow(lottoPaymentValidations.payAmountUnit1000.errorMessage); + }); + + test('1000원 당 1장의 로또 티켓을 발행해야 한다.', () => { + const { lottoTickets } = LottoPayment.createLottoTickets({ + payAmount: 8000, + }) + expect(lottoTickets.length).toBe(8); + }); +}); diff --git a/src/__tests__/lottoSystem.test.js b/src/__tests__/lottoSystem.test.js new file mode 100644 index 0000000..52ec929 --- /dev/null +++ b/src/__tests__/lottoSystem.test.js @@ -0,0 +1,145 @@ +import LottoSystem from '../domain/LottoSystem.js'; +import { lottoSystemValidations } from '../validations/lottoSystem.js'; +import createLottoNumbers from '../domain/createLottoNumbers.js'; + +vi.mock('../domain/createLottoNumbers'); + +describe('로또 시스템 테스트', async () => { + const originalCreateLottoNumbers = await vi + .importActual('../domain/createLottoNumbers') + .then((module) => module.default); + + beforeEach(() => { + createLottoNumbers.mockReset(); + createLottoNumbers.mockImplementation(originalCreateLottoNumbers); + }); + + test('로또 시스템은 순위가 올바르게 매겨진 랭킹 규칙만 사용한다.', () => { + const rankingRuleHasInvalidRank1 = [ + { + matchCount: 3, + bonusMatch: false, + profit: 1000, + rank: -1, + }, + ]; + expect( + () => + new LottoSystem({ + rankingRule: rankingRuleHasInvalidRank1, + }), + ).toThrow(lottoSystemValidations.validRanks.errorMessage); + + const rankingRuleHasInvalidRank2 = [ + { + matchCount: 3, + bonusMatch: false, + profit: 1000, + rank: 3, + }, + ]; + expect( + () => + new LottoSystem({ + rankingRule: rankingRuleHasInvalidRank2, + }), + ).toThrow(lottoSystemValidations.hasAllRanks.errorMessage); + }); + + test('로또 시스템은 유효한 matchCount, bonus, profit 이 포함된 랭킹 규칙만 사용한다.', () => { + const rankingRuleHasInvalidMatchCount = [ + { + bonusMatch: true, + profit: 1000, + rank: 1, + matchCount: 100, + }, + ]; + expect( + () => + new LottoSystem({ + rankingRule: rankingRuleHasInvalidMatchCount, + }), + ).toThrow(lottoSystemValidations.validMatchCounts.errorMessage); + + const rankingRuleHasInvalidBonusMatch = [ + { + profit: 1000, + rank: 1, + matchCount: 3, + bonusMatch: 'false', + }, + ]; + expect( + () => + new LottoSystem({ + rankingRule: rankingRuleHasInvalidBonusMatch, + }), + ).toThrow(lottoSystemValidations.validBonusMatches.errorMessage); + + const rankingRuleHasInvalidProfit = [ + { + bonusMatch: true, + profit: '1000', + rank: 1, + matchCount: 3, + }, + ]; + expect( + () => + new LottoSystem({ + rankingRule: rankingRuleHasInvalidProfit, + }), + ).toThrow(lottoSystemValidations.validProfits.errorMessage); + }); + + test('로또 시스템은 정확한 개수의 로또를 구매한다.', () => { + const lottoSystem = new LottoSystem(); + lottoSystem.payLottoTicket(10000); + + expect(lottoSystem.ticketCount).toBe(10); + expect(lottoSystem.paidAmount).toBe(10000); + }); + + test('로또 시스템은 발행한 로또들을 등수 별로 분류할 수 있다.', () => { + createLottoNumbers + .mockReturnValueOnce([1, 2, 3, 4, 5, 6]) // 1등 + .mockReturnValueOnce([1, 2, 3, 4, 5, 7]) // 2등 + .mockReturnValueOnce([1, 2, 3, 4, 5, 7]) // 2등 + .mockReturnValueOnce([1, 2, 3, 4, 15, 16]) // 4등 + .mockReturnValueOnce([1, 2, 3, 4, 15, 16]) // 4등 + .mockReturnValueOnce([1, 2, 3, 4, 15, 16]); // 4등 + + const lottoSystem = new LottoSystem(); + lottoSystem.setWinningLotto([1, 2, 3, 4, 5, 6], 7); + lottoSystem.payLottoTicket(6000); + + expect(lottoSystem.lottoRankingResult.find(({ rank }) => rank === 1).ticketList.length).toBe(1); + expect(lottoSystem.lottoRankingResult.find(({ rank }) => rank === 2).ticketList.length).toBe(2); + expect(lottoSystem.lottoRankingResult.find(({ rank }) => rank === 4).ticketList.length).toBe(3); + }); + + test('로또 시스템은 총 수익을 계산할 수 있다.', () => { + createLottoNumbers + .mockReturnValueOnce([1, 2, 3, 4, 5, 6]) // 1등 + .mockReturnValueOnce([1, 2, 3, 4, 15, 16]); // 4등 + + const lottoSystem = new LottoSystem(); + lottoSystem.setWinningLotto([1, 2, 3, 4, 5, 6], 7); + lottoSystem.payLottoTicket(2000); + + expect(lottoSystem.profitAmount).toBe(2000050000); + }); + + test('로또 시스템은 수익율을 계산할 수 있다.', () => { + createLottoNumbers + .mockReturnValueOnce([1, 2, 3, 4, 15, 16]) // 4등 = 50000 + .mockImplementation(() => [11, 12, 13, 14, 15, 16]); + + const lottoSystem = new LottoSystem(); + lottoSystem.setWinningLotto([1, 2, 3, 4, 5, 6], 7); + lottoSystem.payLottoTicket(100000); + + expect(lottoSystem.profitRatio).toBe(50); + }); +}); diff --git a/src/__tests__/sum.test.js b/src/__tests__/sum.test.js deleted file mode 100644 index efc011c..0000000 --- a/src/__tests__/sum.test.js +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, test, expect } from "vitest"; - -function sum(...args) { - return args.reduce((a, b) => a+ b); -} - -describe('예제 테스트입니다.', () => { - test('sum > ', () => { - expect(sum(1,2,3,4,5)).toBe(15); - }) -}) diff --git a/src/constants/lottoRankingRule.js b/src/constants/lottoRankingRule.js new file mode 100644 index 0000000..7772b12 --- /dev/null +++ b/src/constants/lottoRankingRule.js @@ -0,0 +1,32 @@ +const LOTTO_RANKING_DATA = [ + { + rank: 1, + matchCount: 6, + profit: 2000000000, + }, + { + rank: 2, + matchCount: 5, + bonusMatch: true, + profit: 30000000, + }, + { + rank: 3, + matchCount: 5, + bonusMatch: false, + profit: 1500000, + }, + { + rank: 4, + matchCount: 4, + profit: 50000, + }, + { + rank: 5, + matchCount: 3, + profit: 5000, + }, +]; + + +export default LOTTO_RANKING_DATA; diff --git a/src/constants/lottoType.js b/src/constants/lottoType.js new file mode 100644 index 0000000..d35802a --- /dev/null +++ b/src/constants/lottoType.js @@ -0,0 +1,6 @@ +const LOTTO_TYPE = { + TICKET: Symbol('lottoTicket'), + WINNING: Symbol('winningLotto'), +}; + +export default LOTTO_TYPE; diff --git a/src/domain/Lotto.js b/src/domain/Lotto.js new file mode 100644 index 0000000..e4c4732 --- /dev/null +++ b/src/domain/Lotto.js @@ -0,0 +1,30 @@ +import { validateLotto } from '../validations/lotto.js'; +import LOTTO_TYPE from '../constants/lottoType.js'; + +export default class Lotto { + constructor({ type, numbers, bonusNumber = null }) { + Lotto.#validate({ type, numbers, bonusNumber }); + this.type = type; + this.numbers = numbers; + this.bonusNumber = bonusNumber; + } + + static #validate(lottoProps) { + validateLotto({ + target: lottoProps, + }); + } +} + +export const createWinningLotto = (numbers, bonusNumber) => + new Lotto({ + type: LOTTO_TYPE.WINNING, + numbers, + bonusNumber, + }); + +export const createLottoTicket = (numbers) => + new Lotto({ + type: LOTTO_TYPE.TICKET, + numbers, + }); diff --git a/src/domain/LottoMatcher.js b/src/domain/LottoMatcher.js new file mode 100644 index 0000000..7b59e6e --- /dev/null +++ b/src/domain/LottoMatcher.js @@ -0,0 +1,27 @@ +import { validateLottoMatcher } from '../validations/lottoMatcher.js'; + +export default class LottoMatcher { + static matchLotto({ winningLotto, lottoTickets }) { + LottoMatcher.#validate({ winningLotto, lottoTickets }); + + return lottoTickets.map((lottoTicket) => ({ + lotto: lottoTicket, + matchCount: LottoMatcher.#getMatchCount(lottoTicket.numbers, winningLotto), + bonusMatch: LottoMatcher.#getBonusMatch(lottoTicket.numbers, winningLotto), + })); + } + + static #validate(lottoMatcherProps) { + validateLottoMatcher({ + target: lottoMatcherProps, + }); + } + + static #getMatchCount(ticketNumbers, winningLotto) { + return ticketNumbers.filter((ticketNumber) => winningLotto.numbers.includes(ticketNumber)).length; + } + + static #getBonusMatch(ticketNumbers, winningLotto) { + return ticketNumbers.includes(winningLotto.bonusNumber); + } +} diff --git a/src/domain/LottoPayment.js b/src/domain/LottoPayment.js new file mode 100644 index 0000000..e37909b --- /dev/null +++ b/src/domain/LottoPayment.js @@ -0,0 +1,24 @@ +import { validateLottoPayment } from '../validations/lottoPayment.js'; +import createLottoNumbers from './createLottoNumbers.js'; +import { createLottoTicket } from './Lotto.js'; + +export default class LottoPayment { + static createLottoTickets({ payAmount }) { + LottoPayment.#validate({ payAmount }); + + const ticketCount = Math.floor(payAmount / 1000); + return { + lottoTickets: Array.from({ length: ticketCount }, () => { + const numbers = createLottoNumbers(); + return createLottoTicket(numbers); + }), + paidAmount: payAmount, + }; + } + + static #validate(lottoPaymentProps) { + validateLottoPayment({ + target: lottoPaymentProps, + }); + } +} diff --git a/src/domain/LottoSystem.js b/src/domain/LottoSystem.js new file mode 100644 index 0000000..37f82d5 --- /dev/null +++ b/src/domain/LottoSystem.js @@ -0,0 +1,87 @@ +import { createWinningLotto } from './Lotto.js'; +import LottoPayment from './LottoPayment.js'; +import LottoMatcher from './LottoMatcher.js'; +import LOTTO_RANKING_RULE from '../constants/lottoRankingRule.js'; +import { validateLottoSystem } from '../validations/lottoSystem.js'; +import cloneDeep from "../utils/cloneDeep.js"; + +export default class LottoSystem { + #rankingRule + #lottoData + + constructor({ rankingRule = LOTTO_RANKING_RULE } = {}) { + LottoSystem.#validate({ rankingRule }); + + this.#rankingRule = rankingRule.sort((a, b) => b.rank - a.rank); + this.#lottoData = { + winningLotto: null, + lottoTickets: [], + paidAmount: 0 + }; + } + + static #validate(lottoSystemProps) { + validateLottoSystem({ + target: lottoSystemProps, + }); + } + + setWinningLotto(winningNumbers, bonusNumber) { + this.#setLottoData({ + winningLotto: createWinningLotto(winningNumbers, bonusNumber) + }) + } + + payLottoTicket(payAmount) { + const { lottoTickets, paidAmount } = LottoPayment.createLottoTickets({ payAmount }) + this.#setLottoData({ + lottoTickets, + paidAmount + }) + } + + #setLottoData(newData) { + this.#lottoData = { ...this.#lottoData, ...newData }; + } + + get paidAmount() { + return this.#lottoData.paidAmount; + } + + get lottoTickets() { + return cloneDeep(this.#lottoData.lottoTickets); + } + + get ticketCount() { + return this.#lottoData.lottoTickets.length; + } + + get lottoRankingResult() { + const lottoMatchResult = LottoMatcher.matchLotto(this.#lottoData); + + return this.#rankingRule.map((rule) => ({ + ...rule, + ticketList: lottoMatchResult + .filter(({ matchCount, bonusMatch }) => + this.#isSameMatchCount(matchCount, rule.matchCount) + && this.#isSameBonusMatch(bonusMatch, rule.bonusMatch)) + .map(({ lotto }) => cloneDeep(lotto)), + })); + } + + get profitAmount() { + return this.lottoRankingResult.reduce((sum, { ticketList, profit }) => sum + profit * ticketList.length, 0); + } + + get profitRatio() { + return parseFloat(((this.profitAmount / this.paidAmount) * 100).toFixed(2)); + } + + #isSameMatchCount(ticketMatchCount, ruleMatchCount) { + return ticketMatchCount === ruleMatchCount; + } + + #isSameBonusMatch(ticketBonusMatch, ruleBonusMatch) { + return ruleBonusMatch === undefined || ruleBonusMatch === ticketBonusMatch; + } +} diff --git a/src/domain/LottoSystemControl.js b/src/domain/LottoSystemControl.js new file mode 100644 index 0000000..58aeda6 --- /dev/null +++ b/src/domain/LottoSystemControl.js @@ -0,0 +1,39 @@ +export default class LottoSystemControl { + constructor({ lottoSystem, viewer }) { + this.lottoSystem = lottoSystem; + this.viewer = viewer; + } + + async setUpPayAmount() { + try { + const payAmount = await this.viewer.readPayAmount(); + this.lottoSystem.payLottoTicket(payAmount); + } catch (error) { + this.viewer.displayError(error); + await this.setUpPayAmount(); + } + } + + async setUpWinningLotto() { + try { + const winningNumbers = await this.viewer.readWinningNumbers(); + const bonusNumber = await this.viewer.readBonusNumber(); + this.lottoSystem.setWinningLotto(winningNumbers, bonusNumber); + } catch (error) { + this.viewer.displayError(error); + await this.setUpWinningLotto(); + } + } + + async run() { + await this.setUpPayAmount(); + this.viewer.displayPaidCount(this.lottoSystem); + this.viewer.displayLottoTickets(this.lottoSystem); + + await this.setUpWinningLotto(); + this.viewer.displayLottoResult(this.lottoSystem); + this.viewer.displayProfitRatio(this.lottoSystem); + + this.viewer.finish(); + } +} diff --git a/src/domain/LottoSystemView.js b/src/domain/LottoSystemView.js new file mode 100644 index 0000000..409adf0 --- /dev/null +++ b/src/domain/LottoSystemView.js @@ -0,0 +1,77 @@ +import ConsolePrinter from '../service/ConsolePrinter.js'; +import ConsoleReader from '../service/ConsoleReader.js'; +import formatCurrency from '../utils/formatCurrency.js'; + +export default class LottoSystemView { + constructor() { + this.printer = new ConsolePrinter({ + paidCount: '%{1}개를 구매했습니다.', + lottoTicket: '%{1} | %{2} | %{3} | %{4} | %{5} | %{6}', + line: '---------------------------------------------', + ranking: '%{1}등 : %{2}개 일치 (%{3}원) - %{4}개', + rankingWithBonus: '%{1}등 : %{2}개 일치, 보너스볼 일치 (%{3}원) - %{4}개', + profitRatio: '총 수익률은 %{1}% 입니다.', + error: '⚠️ %{1}', + }); + this.reader = new ConsoleReader(); + } + + async readPayAmount() { + const answer = await this.reader.read('> 구입 금액을 입력해 주세요. : '); + return Number(answer); + } + + async readWinningNumbers() { + const answer = await this.reader.read('> 당첨 번호를 입력해 주세요. : '); + return answer.split(',').map(Number); + } + + async readBonusNumber() { + const answer = await this.reader.read('> 보너스 번호를 입력해 주세요. : '); + return Number(answer); + } + + displayPaidCount({ ticketCount }) { + this.printer.lineBreak(); + this.printer.printWithTemplate('paidCount', [ticketCount]); + } + + displayLottoTickets({ lottoTickets }) { + lottoTickets.forEach(({ numbers }) => { + const numbersForPrint = numbers.map((number) => `${number}`.padStart(2, '0')).sort(); + this.printer.printWithTemplate('lottoTicket', numbersForPrint); + }); + this.printer.lineBreak(); + } + + displayLottoResult({ lottoRankingResult }) { + this.printer.lineBreak(); + this.printer.print('당첨 통계'); + this.printer.printWithTemplate('line'); + + lottoRankingResult.forEach(({ rank, matchCount, bonusMatch, profit, ticketList }) => { + const resultForPrint = [rank, matchCount, formatCurrency(profit), ticketList.length]; + if (bonusMatch) { + this.printer.printWithTemplate('rankingWithBonus', resultForPrint); + } else { + this.printer.printWithTemplate('ranking', resultForPrint); + } + }); + } + + displayProfitRatio({ profitRatio }) { + this.printer.printWithTemplate('line'); + this.printer.printWithTemplate('profitRatio', [profitRatio]); + this.printer.lineBreak(); + } + + displayError(error) { + this.printer.lineBreak(); + this.printer.printWithTemplate('error', [error.message]); + this.printer.lineBreak(); + } + + finish() { + this.reader.close(); + } +} diff --git a/src/domain/createLottoNumbers.js b/src/domain/createLottoNumbers.js new file mode 100644 index 0000000..e2d1f45 --- /dev/null +++ b/src/domain/createLottoNumbers.js @@ -0,0 +1,11 @@ +import { getRandomNumber } from '../utils/getRandomNumber.js'; + +const createLottoNumbers = () => { + const lottoNumbers = new Set(); + while (lottoNumbers.size < 6) { + lottoNumbers.add(getRandomNumber(1, 45)); + } + return [...lottoNumbers]; +}; + +export default createLottoNumbers; diff --git a/src/main.js b/src/main.js index 96bab59..416c9ee 100644 --- a/src/main.js +++ b/src/main.js @@ -1,5 +1,17 @@ -function main() { - console.log('main의 내용을 채워주세요'); +import LottoSystemControl from './domain/LottoSystemControl.js'; +import LottoSystem from './domain/LottoSystem.js'; +import LOTTO_RANKING_RULE from './constants/lottoRankingRule.js'; +import LottoSystemView from './domain/LottoSystemView.js'; + +async function main() { + const lottoSystemControl = new LottoSystemControl({ + lottoSystem: new LottoSystem({ + rankingRule: LOTTO_RANKING_RULE + }), + viewer: new LottoSystemView() + }); + + await lottoSystemControl.run(); } main(); diff --git a/src/service/ConsolePrinter.js b/src/service/ConsolePrinter.js new file mode 100644 index 0000000..41d1ada --- /dev/null +++ b/src/service/ConsolePrinter.js @@ -0,0 +1,31 @@ +export default class ConsolePrinter { + constructor(template) { + this.template = template; + } + + format(templateKey, messages) { + let result = this.template[templateKey]; + if (messages && messages.length > 0) { + messages.forEach((message, index) => { + result = result.replaceAll(`%{${index + 1}}`, message); + }); + } + return result; + } + + print(...messages) { + console.log(...messages); + } + + printWithTemplate(templateKey, messages) { + if (this.template.hasOwnProperty(templateKey)) { + console.log(this.format(templateKey, messages)); + } else { + console.log(...messages); + } + } + + lineBreak() { + console.log(''); + } +} diff --git a/src/service/ConsoleReader.js b/src/service/ConsoleReader.js new file mode 100644 index 0000000..682c2db --- /dev/null +++ b/src/service/ConsoleReader.js @@ -0,0 +1,37 @@ +import createReadline from '../utils/createReadline.js'; + +export default class ConsoleReader { + constructor() { + this.readLine = createReadline(); + } + + askQuestion(message) { + return new Promise((resolve) => { + this.readLine.question(message, (input) => { + resolve(input); + }); + }); + } + + findInputError(input, validations = []) { + const { errorMessage } = validations.find(({ check }) => !check(input)) ?? {}; + return errorMessage; + } + + read(message, validations = []) { + const processInput = async () => { + const input = await this.askQuestion(message); + const errorMessage = this.findInputError(input, validations); + + if (errorMessage) { + return processInput(); + } + return input; + }; + return processInput(); + } + + close() { + this.readLine.close(); + } +} diff --git a/src/utils/cloneDeep.js b/src/utils/cloneDeep.js new file mode 100644 index 0000000..ed4cbd0 --- /dev/null +++ b/src/utils/cloneDeep.js @@ -0,0 +1,23 @@ +const cloneDeep = (obj) => { + if (obj === null || typeof obj !== 'object') { + return obj; + } + + if (Array.isArray(obj)) { + const clonedArray = []; + obj.forEach(item => { + clonedArray.push(cloneDeep(item)); + }) + return clonedArray; + } + + const clonedObj = {}; + for (const key in obj) { + if (obj.hasOwnProperty(key)) { + clonedObj[key] = cloneDeep(obj[key]); + } + } + return clonedObj; +} + +export default cloneDeep; diff --git a/src/utils/createReadline.js b/src/utils/createReadline.js new file mode 100644 index 0000000..3131ce1 --- /dev/null +++ b/src/utils/createReadline.js @@ -0,0 +1,15 @@ +import readline from 'readline'; + +const createReadline = () => { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + rl.isOpened = true; + rl.on('close', () => { + rl.isOpened = false; + }); + return rl; +}; + +export default createReadline; diff --git a/src/utils/formatCurrency.js b/src/utils/formatCurrency.js new file mode 100644 index 0000000..c8a318b --- /dev/null +++ b/src/utils/formatCurrency.js @@ -0,0 +1,5 @@ +const formatCurrency = (number) => { + return new Intl.NumberFormat('ko-KR').format(number); +}; + +export default formatCurrency; diff --git a/src/utils/getRandomNumber.js b/src/utils/getRandomNumber.js new file mode 100644 index 0000000..c624e58 --- /dev/null +++ b/src/utils/getRandomNumber.js @@ -0,0 +1,3 @@ +export const getRandomNumber = (min, max) => { + return Math.floor(Math.random() * (max - min + 1)) + min; +}; diff --git a/src/validations/createValidator.js b/src/validations/createValidator.js new file mode 100644 index 0000000..aaef61b --- /dev/null +++ b/src/validations/createValidator.js @@ -0,0 +1,14 @@ +const createValidator = (validations) => { + return ({ target, validationKeys }) => { + (validationKeys ?? Object.keys(validations)).forEach((key) => { + if (!validations.hasOwnProperty(key)) { + throw new Error('올바른 검사 키가 아닙니다.'); + } + if (!validations[key].check(target)) { + throw new Error(validations[key].errorMessage); + } + }); + }; +}; + +export default createValidator; diff --git a/src/validations/lotto.js b/src/validations/lotto.js new file mode 100644 index 0000000..85eee23 --- /dev/null +++ b/src/validations/lotto.js @@ -0,0 +1,41 @@ +import createValidator from './createValidator.js'; +import LOTTO_TYPE from '../constants/lottoType.js'; + +export const lottoValidations = { + lottoType: { + check: ({ type }) => type === LOTTO_TYPE.WINNING || type === LOTTO_TYPE.TICKET, + errorMessage: '로또는 ticket, winning 두 가지 유형이어야 합니다.', + }, + lottoNumbersLength: { + check: ({ numbers }) => numbers.length === 6, + errorMessage: '로또 번호는 6개여야 합니다.', + }, + winningLottoHasBonus: { + check: ({ type, bonusNumber }) => (type === LOTTO_TYPE.WINNING ? Boolean(bonusNumber) : true), + errorMessage: '당첨 로또 번호는 보너스 번호를 가져야 합니다.', + }, + ticketLottoBonusNull: { + check: ({ type, bonusNumber }) => (type === LOTTO_TYPE.TICKET ? bonusNumber === null : true), + errorMessage: '구매한 로또 번호는 보너스 번호가 없어야 합니다.', + }, + lottoEachUnique: { + check: ({ numbers, bonusNumber }) => new Set(numbers).size === numbers.length && !numbers.includes(bonusNumber), + errorMessage: '로또 번호는 각각 달라야 합니다.', + }, + lottoInteger: { + check: ({ numbers, bonusNumber }) => { + const lottoNumbers = bonusNumber === null ? numbers : [...numbers, bonusNumber]; + return lottoNumbers.every((number) => Number.isInteger(number)); + }, + errorMessage: '모든 로또 번호는 정수여야 합니다.', + }, + lottoRange: { + check: ({ numbers, bonusNumber }) => { + const lottoNumbers = bonusNumber === null ? numbers : [...numbers, bonusNumber]; + return lottoNumbers.every((number) => 1 <= number && number <= 45); + }, + errorMessage: '모든 로또 번호는 1 이상 45 이하여야 합니다.', + }, +}; + +export const validateLotto = createValidator(lottoValidations); diff --git a/src/validations/lottoMatcher.js b/src/validations/lottoMatcher.js new file mode 100644 index 0000000..3887a84 --- /dev/null +++ b/src/validations/lottoMatcher.js @@ -0,0 +1,17 @@ +import Lotto from '../domain/Lotto.js'; +import createValidator from './createValidator.js'; +import LOTTO_TYPE from '../constants/lottoType.js'; + +export const lottoMatcherValidations = { + validWinningLotto: { + check: ({ winningLotto }) => winningLotto instanceof Lotto && winningLotto.type === LOTTO_TYPE.WINNING, + errorMessage: '유효한 로또 당첨 정보가 아닙니다.', + }, + validLottoTickets: { + check: ({ lottoTickets }) => + lottoTickets.every((lottoTicket) => lottoTicket instanceof Lotto && lottoTicket.type === LOTTO_TYPE.TICKET), + errorMessage: '유효하지 않은 로또 티켓이 존재합니다.', + }, +}; + +export const validateLottoMatcher = createValidator(lottoMatcherValidations); diff --git a/src/validations/lottoPayment.js b/src/validations/lottoPayment.js new file mode 100644 index 0000000..0a56a3a --- /dev/null +++ b/src/validations/lottoPayment.js @@ -0,0 +1,14 @@ +import createValidator from './createValidator.js'; + +export const lottoPaymentValidations = { + payAmountInteger: { + check: ({ payAmount }) => Number.isInteger(payAmount), + errorMessage: '로또 결제 금액은 숫자(정수)여야 합니다.', + }, + payAmountUnit1000: { + check: ({ payAmount }) => payAmount % 1000 === 0, + errorMessage: '로또 결제는 1000원 단위로만 가능합니다.', + }, +}; + +export const validateLottoPayment = createValidator(lottoPaymentValidations); diff --git a/src/validations/lottoSystem.js b/src/validations/lottoSystem.js new file mode 100644 index 0000000..eabd178 --- /dev/null +++ b/src/validations/lottoSystem.js @@ -0,0 +1,30 @@ +import createValidator from './createValidator.js'; + +export const lottoSystemValidations = { + validRanks: { + check: ({ rankingRule }) => rankingRule.every(({ rank }) => Number.isInteger(rank) && rank > 0), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(rank) 포함되어 있습니다.', + }, + validMatchCounts: { + check: ({ rankingRule }) => + rankingRule.every(({ matchCount }) => Number.isInteger(matchCount) && 0 < matchCount && matchCount <= 6), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(matchCount) 포함되어 있습니다.', + }, + validBonusMatches: { + check: ({ rankingRule }) => rankingRule.every(({ bonusMatch }) => bonusMatch === undefined || typeof bonusMatch === 'boolean'), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(bonusMatch) 포함되어 있습니다.', + }, + validProfits: { + check: ({ rankingRule }) => rankingRule.every(({ profit }) => Number.isInteger(profit) && profit > 0), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(profit) 포함되어 있습니다.', + }, + hasAllRanks: { + check: ({ rankingRule }) => { + const ranks = rankingRule.map((data) => data.rank).sort((a, b) => a - b); + return rankingRule.length > 0 && ranks.every((rank, index) => rank === index + 1); + }, + errorMessage: '로또 랭킹 규칙에 모든 등수가 빠짐없이 있어야 합니다.', + }, +}; + +export const validateLottoSystem = createValidator(lottoSystemValidations); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..b758f89 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}) \ No newline at end of file