diff --git a/README.md b/README.md index e078fd41..bd5d4bcf 100644 --- a/README.md +++ b/README.md @@ -1 +1,54 @@ # javascript-racingcar-precourse +- 프로그램 입출력 처리 + - 입력 + - 경주할 자동차 이름을 입력받는다 + - 시도할 횟수를 입력받는다 + - 출력 + - 실행결과를 출력한다 + - 최종 우승자를 출력한다 + - 예외처리 + + - 공통 + - 입력/출력에 사용할 상수 변수들을 따로 관리한다 + - 출력용 문자열을 뽑아낼 함수를 따로 만들어 작성한다 + +- 자동차 경주 계산 + - 경주할 자동차를 파싱한다 + + - 경기 결과 + - 경주 1회를 진행한다 + - 한번 시도시 경주할 자동차가 얼마나 전진했는지 랜덤으로 얻는다 + - 랜덤 숫자을 얻는다 + - 전진 여부 기준으로 계산한다 + - 시도할 횟수만큼 경주를 진행한다 + - 전진 조건을 변수로 따로 관리한다 + - 랜덤으로 뽑아낼 범위 조건을 변수로 따로 관리한다 + - 랜덤으로 숫자를 뽑아내는 함수를 따로 관리한다 + + - 최종 우승자 계산 + +- 에러 예외 케이스 + - 에러 처리: [ERROR] 형식을 안정적으로 처리하도록 공통화 + + - 입력 + - 자동차 이름을 사용자가 입력하지 않으면 에러 처리 + - 이름이 중복된 경우 에러 처리한다 + - 이름을 하나만 입력한 경우 에러 처리한다 + - 이름을 5자이상 입력시 에러 처리한다 + + - 계산 + - 우승자가 없는 경우 + + - 출력 + - 시도할 횟수를 사용자가 입력하지 않으면 에러 처리 + - 시도할 횟수를 숫자가 아닌걸로 입력하면 에러 처리 + +- 자동차 경주 게임에서 변경될 만한 내용은 따로 추려서 + 변경에 유연하게 대처할수 있도록 코드를 개션한다 + - 입력 + - 5글자 이상 + - 구분자 , + - 계산 + - 전진 조건: 4 이상 + - 출력 + - 최종 우승자 , 로 구분 \ No newline at end of file diff --git a/__tests__/CustomTest.js b/__tests__/CustomTest.js new file mode 100644 index 00000000..9595216a --- /dev/null +++ b/__tests__/CustomTest.js @@ -0,0 +1,176 @@ +import App from "../src/App.js"; +import { MissionUtils } from "@woowacourse/mission-utils"; + +const mockQuestions = (inputs) => { + MissionUtils.Console.readLineAsync = jest.fn(); + + MissionUtils.Console.readLineAsync.mockImplementation(() => { + const input = inputs.shift(); + return Promise.resolve(input); + }); +}; + +const mockRandoms = (numbers) => { + MissionUtils.Random.pickNumberInRange = jest.fn(); + + numbers.reduce((acc, number) => { + return acc.mockReturnValueOnce(number); + }, MissionUtils.Random.pickNumberInRange); +}; + +const getLogSpy = () => { + const logSpy = jest.spyOn(MissionUtils.Console, "print"); + logSpy.mockClear(); + return logSpy; +}; + +function getDummyInputs(cars, attemps) { + return [cars.join(','), attemps]; +} + +function getDummyLogs(races, finalWinner) { + return [ + ...races.flatMap((v) => v).map((race) => `${race.name} : ${'-'.repeat(race.score)}`), + `최종 우승자 : ${finalWinner.join(',')}`, + ]; +} + +function getDummyMockRandoms(races) { + return races.flatMap((v) => v).map((race) => race.number); +} + +describe('자동차 경주', () => { + describe('정상', () => { + test('최종 우승자 얻기 계산(하드코딩)', async () => { + // given + const cars = ['pobi', 'woni']; + const attemps = 3; + + const finalWinner = 'woni'; + const races = [ + [ + { name: 'pobi', score: 0, number: 1 }, + { name: 'woni', score: 6, number: 9 }, + ], + [ + { name: 'pobi', score: 5, number: 8 }, + { name: 'woni', score: 0, number: 2 }, + ], + [ + { name: 'pobi', score: 0, number: 2 }, + { name: 'woni', score: 2, number: 5 }, + ], + ]; + + const inputs = ['pobi,woni', '3']; + const logs = [ + 'pobi : ', + 'woni : ------', + + 'pobi : -----', + 'woni : ', + + 'pobi : ', + 'woni : --', + + '최종 우승자 : woni', + ]; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + mockRandoms([1, 9, 8, 2, 2, 5]); + + // when + const app = new App(); + await app.run(); + + // then + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test('최종 우승자 얻기 계산(함수)', async () => { + // given + const cars = ['pobi', 'woni']; + const attemps = 3; + + const finalWinner = ['woni']; + const races = [ + [ + { name: 'pobi', score: 0, number: 1 }, + { name: 'woni', score: 6, number: 9 }, + ], + [ + { name: 'pobi', score: 5, number: 8 }, + { name: 'woni', score: 0, number: 2 }, + ], + [ + { name: 'pobi', score: 0, number: 2 }, + { name: 'woni', score: 2, number: 5 }, + ], + ]; + + const inputs = getDummyInputs(cars, attemps); + const logs = getDummyLogs(races, finalWinner); + const logSpy = getLogSpy(); + + mockQuestions(inputs); + mockRandoms(getDummyMockRandoms(races)); + + // when + const app = new App(); + await app.run(); + + // then + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test('최종 우승자가 여러명인 경우', async () => { + // given + const cars = ['pobi', 'woni', 'java']; + const attemps = 1; + + const finalWinner = ['woni', 'java']; + const races = [ + [ + { name: 'pobi', score: 0, number: 1 }, + { name: 'woni', score: 6, number: 9 }, + { name: 'java', score: 6, number: 9 }, + ], + ]; + + const inputs = getDummyInputs(cars, attemps); + const logs = getDummyLogs(races, finalWinner); + const logSpy = getLogSpy(); + + mockQuestions(inputs); + mockRandoms(getDummyMockRandoms(races)); + + // when + const app = new App(); + await app.run(); + + // then + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + }); + + describe('예외', () => { + test("자동차 이름이 5자 이하인 경우", async () => { + // given + const inputs = ["pobi,javaji", 1]; + mockQuestions(inputs); + + // when + const app = new App(); + + // then + await expect(app.run()).rejects.toThrow("[ERROR]"); + }); + }); +}); diff --git a/src/App.js b/src/App.js index 091aa0a5..83bd43e1 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,48 @@ +import { Console } from '@woowacourse/mission-utils'; + +import CarRacing from './CarRacing.js'; + +import { INPUT, OUTPUT } from './constants.js'; + class App { - async run() {} + async run() { + try { + const { inputs, attemps } = await this.input(); + + const carRacing = new CarRacing({ inputs, attemps }); + + const races = carRacing.getRaces(); + const finalWinner = carRacing.getFinalWinner(); + + this.output({ races, finalWinner }); + } catch (err) { + throw Error(`[ERROR] ${err}`); + } + } + + async input() { + const inputs = await Console.readLineAsync(`${INPUT.INPUT_CAR_NAME}\n`); + + const attemps = await Console.readLineAsync(`${INPUT.INPUT_ATTEMPS}\n`); + + if (!attemps) throw new Error('[ERROR]'); + + return { inputs, attemps }; + } + + async output({ races, finalWinner }) { + Console.print(`\n${OUTPUT.EXECUTION_RESULT}`); + + races.forEach((race) => { + const raceResult = race.map((v) => `${v.name} : ${'-'.repeat(v.score)}`); + + raceResult.forEach((v) => { + Console.print(`${v}\n`); + }); + }); + + Console.print(`\n${OUTPUT.FINAL_RESULT} : ${finalWinner.join(',')}`); + } } export default App; diff --git a/src/CarRacing.js b/src/CarRacing.js new file mode 100644 index 00000000..754f2479 --- /dev/null +++ b/src/CarRacing.js @@ -0,0 +1,109 @@ +import { Random } from '@woowacourse/mission-utils'; + +class CarRacing { + constructor({ inputs, attemps }) { + this.inputs = inputs; + this.attemps = attemps; + } + + checkValidationCars(cars) { + cars.forEach((car) => { + if (car.length > 5) { + throw '자동차 이름을 5자이하만 가능하다'; + } + }); + + return true; + } + + getCars() { + const DELIMETER = ','; + const result = this.inputs.split(DELIMETER); + + if (!this.checkValidationCars(result)) return; + + return result; + } + + getRandom() { + return Random.pickNumberInRange(0, 9); + } + + getScore(number) { + const THREDSHOLD = 4; + const calculatedScore = number - THREDSHOLD + 1; + + return calculatedScore <= 0 ? 0 : calculatedScore; + } + + getRace() { + const cars = this.getCars(); + + return cars.map((car) => { + const randomNumber = this.getRandom(); + const score = this.getScore(randomNumber); + + return { name: car, score }; + }); + } + + getRaces() { + const { attemps } = this; + + const result = Array.from({ length: attemps }).map(() => this.getRace()); + + this.races = result; + + return result; + } + + calculateWinner(race) { + const highScore = race.reduce((acc, r) => (r.score > acc ? r.score : acc), 0); + + return race.filter((r) => r.score === highScore).map((r) => r.name); + } + + calculateRaceWinners(races) { + return races.map((race) => this.calculateWinner(race)); + } + + calculateWinCount(raceWinners) { + const cars = this.getCars(); + + const carCount = cars.reduce((acc, car) => { + acc[car] = 0; + return acc; + }, {}); + + return raceWinners.reduce((acc, winners) => { + winners.forEach((winner) => { + acc[winner] += 1; + }); + + return acc; + }, carCount); + } + + calculateFinalWinner(raceWinners) { + // { pobi: 2, woni: 1 } + const winCount = this.calculateWinCount(raceWinners); + + const highTimes = Object.values(winCount).reduce((acc, v) => (v > acc ? v : acc), 0); + + return Object.entries(winCount) + .filter(([_, times]) => times === highTimes) + .map((([name]) => name)); + } + + getFinalWinner() { + // [['pobi', 'woni'], ['pobi']] + const raceWinners = this.calculateRaceWinners(this.races); + + // ['pobi'] || ['pobi', 'woni'] + const finalWinner = this.calculateFinalWinner(raceWinners); + + return finalWinner; + } +} + +export default CarRacing; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 00000000..50c4a667 --- /dev/null +++ b/src/constants.js @@ -0,0 +1,8 @@ +export const INPUT = { + INPUT_CAR_NAME: '경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)', + INPUT_ATTEMPS: '시도할 횟수는 몇 회인가요?', +}; +export const OUTPUT = { + EXECUTION_RESULT: '실행결과', + FINAL_RESULT: '최종 우승자', +};