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..5e776df --- /dev/null +++ b/src/__tests__/lotto.test.js @@ -0,0 +1,129 @@ +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); + }); + + test('숫자 리스트를 통해 로또 티켓을 생성할 수 있다.', () => { + expect(new Lotto([1, 2, 3, 4, 5, 6]).numbers).toEqual([1, 2, 3, 4, 5, 6]); + }); + + test('로또에서 특정 번호가 포함되어있는지 여부를 알 수 있다.', () => { + const lotto1 = new Lotto([1, 2, 3, 4, 5, 6]); + const lotto2 = new Lotto({ + type: LOTTO_TYPE.WINNING, + numbers: [1, 2, 3, 4, 5, 6], + bonusNumber: 7, + }); + + expect(lotto1.contain(7)).toBe(false); + expect(lotto2.contain(7)).toBe(true); + }); + + test('유효한 로또로만 로또를 비교할 수 있다.', () => { + const lotto1 = new Lotto([1, 2, 3, 4, 5, 6]); + const lotto2 = [1, 2, 3, 4, 5, 6]; + + expect(() => lotto1.matchNumbers(lotto2)).toThrow(lottoValidations.lottoInstance.errorMessage); + }); + + test('두 로또를 비교하여 겹치는 숫자 목록을 확인할 수 있다.', () => { + const lotto1 = new Lotto([1, 2, 3, 4, 5, 6]); + const lotto2 = new Lotto({ + type: LOTTO_TYPE.WINNING, + numbers: [3, 4, 5, 6, 7, 8], + bonusNumber: 2, + }); + + expect(lotto1.matchNumbers(lotto2)).toEqual([3, 4, 5, 6]); + expect(lotto2.matchNumbers(lotto1)).toEqual([3, 4, 5, 6]); + }); +}); diff --git a/src/__tests__/lottoPayment.test.js b/src/__tests__/lottoPayment.test.js new file mode 100644 index 0000000..f37c6ba --- /dev/null +++ b/src/__tests__/lottoPayment.test.js @@ -0,0 +1,21 @@ +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(1100)).toThrow(lottoPaymentValidations.payAmountUnit1000.errorMessage); + }); + + test('1000원 당 1장의 로또 티켓을 발행해야 한다.', () => { + const lottoTickets = LottoPayment.createLottoTickets(8000); + expect(lottoTickets.length).toBe(8); + }); +}); diff --git a/src/__tests__/lottoRuleSet.test.js b/src/__tests__/lottoRuleSet.test.js new file mode 100644 index 0000000..097bb76 --- /dev/null +++ b/src/__tests__/lottoRuleSet.test.js @@ -0,0 +1,160 @@ +import LottoRuleSet from '../domain/LottoRuleSet.js'; +import { lottoRuleSetValidations } from '../validations/lottoRuleSet.js'; + +vi.mock('../domain/createLottoNumbers'); + +describe('로또 규칙 테스트', async () => { + test('로또 규칙은 순위가 1위부터 최하위까지 올바르게 매겨져야 한다.', () => { + const rankingRuleHasInvalidRank1 = [ + { + matchCount: 3, + bonusMatch: false, + profit: 1000, + rank: -1, + distribute: 0.5, + }, + ]; + expect( + () => + new LottoRuleSet({ + initialRule: rankingRuleHasInvalidRank1, + }), + ).toThrow(lottoRuleSetValidations.validRanks.errorMessage); + + const rankingRuleHasInvalidRank2 = [ + { + matchCount: 3, + bonusMatch: false, + profit: 1000, + rank: 3, + distribute: 0.5, + }, + ]; + expect( + () => + new LottoRuleSet({ + initialRule: rankingRuleHasInvalidRank2, + }), + ).toThrow(lottoRuleSetValidations.hasAllRanks.errorMessage); + }); + + test('로또 규칙에서 각 순위의 distribute 의 합은 1을 넘으면 안된다.', () => { + const rankingRuleHasInvalidDistributeSum = [ + { + bonusMatch: true, + profit: 1000, + rank: 1, + matchCount: 3, + distribute: 0.5, + }, + { + bonusMatch: true, + profit: 1000, + rank: 2, + matchCount: 2, + distribute: 0.4, + }, + { + bonusMatch: true, + profit: 1000, + rank: 3, + matchCount: 1, + distribute: 0.3, + }, + ]; + expect(() => new LottoRuleSet({ initialRule: rankingRuleHasInvalidDistributeSum })).toThrow( + lottoRuleSetValidations.distributeSum.errorMessage, + ); + }); + + test('로또 규칙에는 유효한 matchCount, bonus, profit, distribute 이 포함되어야 한다.', () => { + const rankingRuleHasInvalidMatchCount = [ + { + bonusMatch: true, + profit: 1000, + rank: 1, + matchCount: 100, + distribute: 0.5, + }, + ]; + expect( + () => + new LottoRuleSet({ + initialRule: rankingRuleHasInvalidMatchCount, + }), + ).toThrow(lottoRuleSetValidations.validMatchCounts.errorMessage); + + const rankingRuleHasInvalidBonusMatch = [ + { + profit: 1000, + rank: 1, + matchCount: 3, + bonusMatch: 'false', + }, + ]; + expect( + () => + new LottoRuleSet({ + initialRule: rankingRuleHasInvalidBonusMatch, + }), + ).toThrow(lottoRuleSetValidations.validBonusMatches.errorMessage); + + const rankingRuleHasInvalidProfit = [ + { + bonusMatch: true, + profit: '1000', + rank: 1, + matchCount: 3, + distribute: 0.5, + }, + ]; + expect( + () => + new LottoRuleSet({ + initialRule: rankingRuleHasInvalidProfit, + }), + ).toThrow(lottoRuleSetValidations.validProfits.errorMessage); + + const rankingRuleHasInvalidDistribute = [ + { + bonusMatch: true, + profit: 1000, + rank: 1, + matchCount: 3, + distribute: -1, + }, + ]; + expect( + () => + new LottoRuleSet({ + initialRule: rankingRuleHasInvalidDistribute, + }), + ).toThrow(lottoRuleSetValidations.validDistribute.errorMessage); + }); + + test('increaseRankProfit은 남은 액수와 distribute 비율을 기반으로 각 순위의 profit을 증가시켜야 한다.', () => { + const initialRule = [ + { + matchCount: 5, + bonusMatch: true, + profit: 1000, + rank: 1, + distribute: 0.6, + }, + { + matchCount: 5, + bonusMatch: false, + profit: 600, + rank: 2, + distribute: 0.4, + }, + ]; + const lottoRuleSet = new LottoRuleSet({ initialRule: initialRule }); + + lottoRuleSet.increaseRankProfit(1000); + const updatedRules = lottoRuleSet.rules; + + expect(updatedRules.find(({ rank }) => rank === 1).profit).toBe(1600); + expect(updatedRules.find(({ rank }) => rank === 2).profit).toBe(1000); + }); +}); diff --git a/src/__tests__/lottoSystem.test.js b/src/__tests__/lottoSystem.test.js new file mode 100644 index 0000000..02bc3b3 --- /dev/null +++ b/src/__tests__/lottoSystem.test.js @@ -0,0 +1,69 @@ +import LottoSystem from '../domain/LottoSystem.js'; +import createLottoNumbers from '../domain/createLottoNumbers.js'; +import Lotto from '../domain/Lotto.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 lottoSystem = new LottoSystem(); + lottoSystem.payLottoTicket(10000); + + expect(lottoSystem.ticketCount).toBe(10); + expect(lottoSystem.paidAmount).toBe(10000); + }); + + test('로또 시스템에서 로또 티켓의 등수를 확인할 수 있다.', () => { + const lottoSystem = new LottoSystem(); + lottoSystem.setWinningLotto([1, 2, 3, 4, 5, 6], 7); + + expect(lottoSystem.getRank(new Lotto([1, 2, 3, 4, 5, 6]))).toBe(1); + expect(lottoSystem.getRank(new Lotto([1, 2, 3, 4, 5, 7]))).toBe(2); + expect(lottoSystem.getRank(new Lotto([1, 2, 3, 4, 5, 8]))).toBe(3); + expect(lottoSystem.getRank(new Lotto([1, 2, 3, 4, 7, 8]))).toBe(4); + expect(lottoSystem.getRank(new Lotto([1, 2, 3, 7, 8, 9]))).toBe(5); + }); + + 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, 5, 6]).mockReturnValue([7, 8, 9, 10, 11, 12]); + + const lottoSystem = new LottoSystem(); + lottoSystem.setWinningLotto([4, 5, 6, 13, 14, 15], 16); + lottoSystem.payLottoTicket(100000); + + expect(lottoSystem.leftPaidAmount).toBe(95000); + }); + + 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..a0c05e8 --- /dev/null +++ b/src/constants/lottoRankingRule.js @@ -0,0 +1,35 @@ +const LOTTO_RANKING_DATA = [ + { + rank: 1, + matchCount: 6, + profit: 2000000000, + distribute: 0.5, + }, + { + rank: 2, + matchCount: 5, + bonusMatch: true, + profit: 30000000, + distribute: 0.3, + }, + { + rank: 3, + matchCount: 5, + bonusMatch: false, + profit: 1500000, + distribute: 0.15, + }, + { + rank: 4, + matchCount: 4, + profit: 50000, + distribute: 0.05, + }, + { + 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..ff85579 --- /dev/null +++ b/src/domain/Lotto.js @@ -0,0 +1,71 @@ +import { validateLotto } from '../validations/lotto.js'; +import LOTTO_TYPE from '../constants/lottoType.js'; + +export default class Lotto { + constructor(lottoProps) { + this.#setLotto( + Array.isArray(lottoProps) + ? { + type: LOTTO_TYPE.TICKET, + numbers: lottoProps, + } + : lottoProps, + ); + } + + contain(number) { + return [...this.numbers, this.bonusNumber].includes(number); + } + + matchNumbers(otherLotto) { + Lotto.validateLotto(otherLotto); + return this.numbers.filter((number) => otherLotto.numbers.includes(number)); + } + + #setLotto({ type, numbers, bonusNumber = null }) { + Lotto.#validateLottoProps({ type, numbers, bonusNumber }); + this.type = type; + this.numbers = numbers; + this.bonusNumber = bonusNumber; + this.#sortNumbers(); + } + + #sortNumbers() { + this.numbers.sort((a, b) => a - b); + } + + static #validateLottoProps(lottoProps) { + validateLotto({ + target: lottoProps, + validationKeys: [ + 'lottoType', + 'lottoNumbersLength', + 'winningLottoHasBonus', + 'ticketLottoBonusNull', + 'lottoEachUnique', + 'lottoInteger', + 'lottoRange', + ], + }); + } + + static validateLotto(lotto) { + validateLotto({ + target: lotto, + validationKeys: ['lottoInstance'], + }); + } +} + +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/LottoPayment.js b/src/domain/LottoPayment.js new file mode 100644 index 0000000..06e2d6c --- /dev/null +++ b/src/domain/LottoPayment.js @@ -0,0 +1,18 @@ +import { validateLottoPayment } from '../validations/lottoPayment.js'; +import createLottoNumbers from './createLottoNumbers.js'; +import Lotto, { createLottoTicket } from './Lotto.js'; + +export default class LottoPayment { + static createLottoTickets(payAmount) { + LottoPayment.#validate({ payAmount }); + + const ticketCount = Math.floor(payAmount / 1000); + return Array.from({ length: ticketCount }, () => new Lotto(createLottoNumbers())); + } + + static #validate(lottoPaymentProps) { + validateLottoPayment({ + target: lottoPaymentProps, + }); + } +} diff --git a/src/domain/LottoRuleSet.js b/src/domain/LottoRuleSet.js new file mode 100644 index 0000000..9106a27 --- /dev/null +++ b/src/domain/LottoRuleSet.js @@ -0,0 +1,36 @@ +import { validateLottoRuleSet } from '../validations/lottoRuleSet.js'; +import cloneDeep from '../utils/cloneDeep.js'; + +export default class LottoRuleSet { + #ruleSet; + constructor({ initialRule }) { + LottoRuleSet.#validate({ initialRule }); + this.#ruleSet = initialRule.sort((a, b) => b.rank - a.rank); + } + + get rulesWithBonusMatch() { + return this.#ruleSet.filter(({ bonusMatch }) => bonusMatch !== undefined); + } + + get rulesWithoutBonusMatch() { + return this.#ruleSet.filter(({ bonusMatch }) => bonusMatch === undefined); + } + + get rules() { + return cloneDeep(this.#ruleSet); + } + + increaseRankProfit(leftAmount) { + this.#ruleSet = this.#ruleSet.map(({ profit, distribute, ...rule }) => ({ + ...rule, + distribute, + profit: distribute ? profit + Math.floor(leftAmount * distribute) : profit, + })); + } + + static #validate(lottoRuleSetProps) { + validateLottoRuleSet({ + target: lottoRuleSetProps, + }); + } +} diff --git a/src/domain/LottoSystem.js b/src/domain/LottoSystem.js new file mode 100644 index 0000000..324362d --- /dev/null +++ b/src/domain/LottoSystem.js @@ -0,0 +1,93 @@ +import { createWinningLotto } from './Lotto.js'; +import LottoPayment from './LottoPayment.js'; +import LOTTO_RANKING_RULE from '../constants/lottoRankingRule.js'; +import cloneDeep from '../utils/cloneDeep.js'; +import LottoRuleSet from './LottoRuleSet.js'; + +export default class LottoSystem { + #ruleSet; + #lottoData; + + constructor({ rankingRule = LOTTO_RANKING_RULE } = {}) { + this.#lottoData = { + winningLotto: null, + lottoTickets: [], + paidAmount: 0, + }; + this.#ruleSet = new LottoRuleSet({ initialRule: rankingRule }); + } + + #setLottoData(newData) { + this.#lottoData = { ...this.#lottoData, ...newData }; + } + + setWinningLotto(winningNumbers, bonusNumber) { + this.#setLottoData({ + winningLotto: createWinningLotto(winningNumbers, bonusNumber), + }); + } + + payLottoTicket(payAmount) { + this.#setLottoData({ + lottoTickets: LottoPayment.createLottoTickets(payAmount), + paidAmount: payAmount, + }); + } + + getRank(lottoTicket) { + const matchedRuleWithBonus = this.#ruleSet.rulesWithBonusMatch.find( + ({ matchCount, bonusMatch }) => + lottoTicket.contain(this.winningLotto.bonusNumber) === bonusMatch && + lottoTicket.matchNumbers(this.winningLotto).length === matchCount, + ); + const matchedRuleWithoutBonus = this.#ruleSet.rulesWithoutBonusMatch.find( + ({ matchCount }) => lottoTicket.matchNumbers(this.winningLotto).length === matchCount, + ); + + if (matchedRuleWithBonus) return matchedRuleWithBonus.rank; + return matchedRuleWithoutBonus?.rank; + } + + getCountByRank(rank) { + return this.lottoTickets.filter((lottoTicket) => this.getRank(lottoTicket) === rank).length; + } + + get lottoRankingResult() { + return this.#ruleSet.rules.map((rule) => ({ + ...rule, + count: this.getCountByRank(rule.rank), + })); + } + + get profitAmount() { + return this.lottoRankingResult.reduce((sum, { count, profit }) => sum + profit * count, 0); + } + + get leftPaidAmount() { + return Math.max(0, this.paidAmount - this.profitAmount); + } + + increaseProfit() { + this.#ruleSet.increaseRankProfit(this.leftPaidAmount); + } + + get profitRatio() { + return parseFloat(((this.profitAmount / this.paidAmount) * 100).toFixed(2)); + } + + get paidAmount() { + return this.#lottoData.paidAmount; + } + + get lottoTickets() { + return cloneDeep(this.#lottoData.lottoTickets); + } + + get winningLotto() { + return cloneDeep(this.#lottoData.winningLotto); + } + + get ticketCount() { + return this.#lottoData.lottoTickets.length; + } +} diff --git a/src/domain/LottoSystemControl.js b/src/domain/LottoSystemControl.js new file mode 100644 index 0000000..23937bd --- /dev/null +++ b/src/domain/LottoSystemControl.js @@ -0,0 +1,45 @@ +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() { + let gameCount = 1; + do { + this.viewer.displayLottoStart(gameCount++); + 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.lottoSystem.increaseProfit(); + } while (await this.viewer.readKeepGoing(this.lottoSystem)); + + this.viewer.finish(); + } +} diff --git a/src/domain/LottoSystemView.js b/src/domain/LottoSystemView.js new file mode 100644 index 0000000..12ba096 --- /dev/null +++ b/src/domain/LottoSystemView.js @@ -0,0 +1,93 @@ +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({ + start: '🍀 제 %{1}회 로또 복권 추첨 🍀', + paidCount: '%{1}개를 구매했습니다.', + lottoTicket: '%{1} | %{2} | %{3} | %{4} | %{5} | %{6}', + line: '---------------------------------------------', + line2: '~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~', + ranking: '%{1}등 : %{2}개 일치 (%{3}원) - %{4}개', + rankingWithBonus: '%{1}등 : %{2}개 일치, 보너스볼 일치 (%{3}원) - %{4}개', + profitRatio: '총 수익률은 %{1}% 입니다.', + error: '⚠️ %{1}', + replay: '게임을 계속하시겠습니까? 남은 수익 %{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); + } + + async readKeepGoing({ leftPaidAmount }) { + this.printer.printWithTemplate('replay', [formatCurrency(leftPaidAmount)]); + const answer = await this.reader.read('다시하기 = S / 그민두기 = 아무키 : '); + return answer.toUpperCase() === 'S'; + } + + displayLottoStart(gameCount) { + this.printer.lineBreak(); + this.printer.printWithTemplate('line2'); + this.printer.printWithTemplate('start', [gameCount]); + this.printer.printWithTemplate('line2'); + } + + 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, count }) => { + const resultForPrint = [rank, matchCount, formatCurrency(profit), count]; + 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..66ebe5c --- /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 = Object.create(Object.getPrototypeOf(obj)); + for (let 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..3602da2 --- /dev/null +++ b/src/validations/lotto.js @@ -0,0 +1,46 @@ +import createValidator from './createValidator.js'; +import LOTTO_TYPE from '../constants/lottoType.js'; +import Lotto from '../domain/Lotto.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 이하여야 합니다.', + }, + lottoInstance: { + check: (lotto) => lotto instanceof Lotto, + errorMessage: '유효한 로또가 아닙니다.', + }, +}; + +export const validateLotto = createValidator(lottoValidations); 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/lottoRuleSet.js b/src/validations/lottoRuleSet.js new file mode 100644 index 0000000..8b9614e --- /dev/null +++ b/src/validations/lottoRuleSet.js @@ -0,0 +1,44 @@ +import createValidator from './createValidator.js'; + +export const lottoRuleSetValidations = { + validRankList: { + check: ({ initialRule }) => Array.isArray(initialRule), + errorMessage: '로또 랭킹 규칙은 배열 형태여야 합니다.', + }, + validRanks: { + check: ({ initialRule }) => initialRule.every(({ rank }) => Number.isInteger(rank) && rank > 0), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(rank) 포함되어 있습니다.', + }, + validMatchCounts: { + check: ({ initialRule }) => + initialRule.every(({ matchCount }) => Number.isInteger(matchCount) && 0 < matchCount && matchCount <= 6), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(matchCount) 포함되어 있습니다.', + }, + validBonusMatches: { + check: ({ initialRule }) => + initialRule.every(({ bonusMatch }) => bonusMatch === undefined || typeof bonusMatch === 'boolean'), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(bonusMatch) 포함되어 있습니다.', + }, + validProfits: { + check: ({ initialRule }) => initialRule.every(({ profit }) => Number.isInteger(profit) && profit > 0), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(profit) 포함되어 있습니다.', + }, + validDistribute: { + check: ({ initialRule }) => + initialRule.every(({ distribute }) => distribute === undefined || (0 < distribute && distribute < 1)), + errorMessage: '로또 랭킹 규칙에 유효하지 않은 값이(distribute) 포함되어 있습니다.', + }, + distributeSum: { + check: ({ initialRule }) => initialRule.reduce((sum, { distribute }) => sum + (distribute ?? 0), 0) <= 1, + errorMessage: '로또 잔여금 분배율의 합은 1을 초과할 수 없습니다.', + }, + hasAllRanks: { + check: ({ initialRule }) => { + const ranks = initialRule.map((data) => data.rank).sort((a, b) => a - b); + return initialRule.length > 0 && ranks.every((rank, index) => rank === index + 1); + }, + errorMessage: '로또 랭킹 규칙에 모든 등수가 빠짐없이 있어야 합니다.', + }, +}; + +export const validateLottoRuleSet = createValidator(lottoRuleSetValidations); 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..7382f40 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + }, +});