diff --git a/README.md b/README.md index e078fd41f..df1015537 100644 --- a/README.md +++ b/README.md @@ -1 +1,87 @@ # javascript-racingcar-precourse + +## 구현할 기능 목록 + +### 1. 기능 구현 (App.js에서 모든 기능 구현) + +1. **입력 기능** + - 사용자의 입력을 통해 경주에 참가할 자동차 이름과 시도할 횟수를 입력받습니다. + - `경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)` + - 예시 입력: `pobi,woni , jun` + - 자동차 이름은 5자 이하로 제한되며, 앞뒤 공백이 제거됩니다. + - 길이가 0인 이름은 입력될 수 없습니다. + - 자동차를 **2개 이상** 입력해야 합니다. + - **중복되는 자동차 이름**은 입력할 수 없습니다. + - `시도할 횟수는 몇 회인가요?` + - 예시 입력: `5` + - 시도 횟수는 **1 이상의 양수**여야 합니다. + - 잘못된 입력 시 `[ERROR]`로 시작하는 오류 메시지를 출력하고 프로그램을 종료합니다. + +2. **자동차 전진 기능** + - 각 자동차는 **주어진 시도 횟수만큼 전진**하거나 **멈추는** 과정을 반복합니다. + - **전진 조건**: 0에서 9 사이의 랜덤 숫자를 생성하여, 숫자가 **4 이상일 경우** 자동차가 전진합니다. + - 전진 여부는 `Random.pickNumberInRange(0,9)`를 이용하여 결정합니다. + +3. **경주 진행 및 결과 출력 기능** + - 각 시도마다 **모든 자동차의 위치**를 출력합니다. + - 출력 예시: + ``` + 실행 결과 + pobi : - + woni : - + jun : + ``` + - 각 **라운드마다 경주 결과**를 모두 출력합니다. + +4. **우승자 출력 기능** + - 모든 시도가 완료된 후 가장 멀리 전진한 자동차를 **우승자**로 선정합니다. + - **우승자가 여러 명**일 경우 쉼표`(,)`로 구분하여 출력합니다. + - 예시 출력: `최종 우승자 : pobi, jun` + + +### 2. 리팩토링 계획 (기능 구현 후) + +1. **모듈화 작업** + - 입력 처리, 게임 로직, 유효성 검사를 각각의 **독립적인 모듈**로 분리합니다. + - 상수값 (메시지, 에러 메시지 등)을 **constants.js** 파일로 분리하여 관리합니다. + - 주요 기능들을 각각 별도의 함수나 클래스로 분리하여 가독성을 높입니다. + +2. **네이밍 규칙 적용** + - 변수, 함수, 클래스 이름을 **Airbnb JavaScript 스타일 가이드**에 맞춰 정리합니다. + - 예를 들어, **상수**는 대문자와 밑줄(`_`)로 구분하고, 변수와 함수는 **카멜 케이스**를 사용합니다. + +3. **깊이(depth) 규칙 준수** + - 들여쓰기 깊이를 최대 **2까지만 허용**하도록 코드를 정리합니다. + - 중첩된 로직이 있을 경우, 이를 **함수로 분리**하여 깊이를 줄이는 방식으로 개선합니다. + +4. **코드 리뷰 및 클린 코드 적용** + - 작성한 코드를 다시 검토하여 **중복 코드 제거**, **불필요한 주석 삭제** 등 **클린 코드** 원칙을 적용합니다. +### 3. 구현 및 리팩토링 과정 +- **1단계**: App.js에서 **모든 기능을 구현**하고 기능이 정상적으로 동작하는지 확인합니다. +- **2단계**: 기능 구현이 완료되면, 코드를 **리팩토링**하여 가독성과 유지보수성을 높입니다. +- **3단계**: Jest를 이용하여 **테스트 케이스 작성**을 진행합니다. + - **입력 값 검증 테스트**: 자동차 이름이 올바른지, 시도 횟수가 유효한지를 검증합니다. + - **파라미터화 테스트**: 다양한 입력 조합에 대해 반복 테스트하기 위해 `test.each()` 또는 `describe.each()`를 사용합니다. + +## 실행 예시 +``` +경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분) +pobi,woni,jun +시도할 횟수는 몇 회인가요? +3 + +실행 결과 +pobi : - +woni : +jun : - + +pobi : -- +woni : - +jun : -- + +pobi : -- +woni : - +jun : -- + +최종 우승자 : pobi, jun +``` diff --git a/__tests__/myCustomTest.js b/__tests__/myCustomTest.js new file mode 100644 index 000000000..1ae67108d --- /dev/null +++ b/__tests__/myCustomTest.js @@ -0,0 +1,109 @@ +import App from '../src/App.js'; +import { MissionUtils } from '@woowacourse/mission-utils'; +import { + VARIOUS_NAME_TEST_CASES, + ROUND_TEST_CASE, + TWO_WINNERS_TEST_CASE, + EXCEPTION_TEST_CASES, +} from '../data/testCasesData.js'; + +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; +}; + +describe('자동차 경주', () => { + describe('기능 테스트', () => { + test.each(VARIOUS_NAME_TEST_CASES)( + '$description', + async ({ inputs, logs }) => { + const MOVING_FORWARD = 4; + const STOP = 3; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + mockRandoms([MOVING_FORWARD, STOP]); + + const app = new App(); + await app.run(); + + for (const log of logs) { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + } + } + ); + }); + + describe('기능 테스트 - 각 라운드 출력', () => { + test('각 라운드 출력 확인', async () => { + const { inputs, logs } = ROUND_TEST_CASE; + const MOVING_FORWARD = 4; + const STOP = 3; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + mockRandoms([ + MOVING_FORWARD, + STOP, + MOVING_FORWARD, + STOP, + MOVING_FORWARD, + STOP, + ]); + + const app = new App(); + await app.run(); + + for (const log of logs) { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + } + }); + }); + + describe('기능 테스트 - 우승자 2인', () => { + test('우승자 2인', async () => { + const { inputs, logs } = TWO_WINNERS_TEST_CASE; + const MOVING_FORWARD = 4; + const logSpy = getLogSpy(); + + mockQuestions(inputs); + mockRandoms([MOVING_FORWARD, MOVING_FORWARD]); + + const app = new App(); + await app.run(); + + for (const log of logs) { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + } + }); + }); + + describe('자동차 경주 예외 테스트', () => { + test.each(EXCEPTION_TEST_CASES)( + '입력 값 검증 - 예외 테스트', + async ({ inputs, expectedError }) => { + mockQuestions(inputs); + + const app = new App(); + + await expect(app.run()).rejects.toThrow(expectedError); + } + ); + }); +}); diff --git a/data/testCasesData.js b/data/testCasesData.js new file mode 100644 index 000000000..ce9f1fcc4 --- /dev/null +++ b/data/testCasesData.js @@ -0,0 +1,100 @@ +import { ERROR_MESSAGE } from '../src/constants/errorMessages.js'; + +const { + INVALID_CAR_NAMES_LENGTH, + INVALID_CAR_NAME_LENGTH, + DUPLICATE_CAR_NAMES, + INVALID_TRY_COUNT, +} = ERROR_MESSAGE; + +const VARIOUS_NAME_TEST_CASES = [ + { + description: '한글, 숫자, 특수문자 통과 테스트', + inputs: ['한글1,한글2', '1'], + logs: ['한글1 : -', '한글2 : ', '최종 우승자 : 한글1'], + }, + { + description: '숫자만 포함된 자동차 이름 테스트', + inputs: ['1234,5678', '1'], + logs: ['1234 : -', '5678 : ', '최종 우승자 : 1234'], + }, + { + description: '한글과 숫자가 혼합된 자동차 이름 테스트', + inputs: ['한글123,한글456', '1'], + logs: ['한글123 : -', '한글456 : ', '최종 우승자 : 한글123'], + }, + { + description: '특수문자만 포함된 자동차 이름 테스트', + inputs: ['!@#$,%^&*', '1'], + logs: ['!@#$ : -', '%^&* : ', '최종 우승자 : !@#$'], + }, + { + description: '한글과 특수문자가 혼합된 자동차 이름 테스트', + inputs: ['한글!@#,한글%^&', '1'], + logs: ['한글!@# : -', '한글%^& : ', '최종 우승자 : 한글!@#'], + }, + { + description: '한글과 영문자가 혼합된 자동차 이름 테스트', + inputs: ['한글abc,한글xyz', '1'], + logs: ['한글abc : -', '한글xyz : ', '최종 우승자 : 한글abc'], + }, +]; + +const ROUND_TEST_CASE = { + inputs: ['pobi,woni', '3'], + logs: [ + 'pobi : -', + 'woni : ', + '', // 첫 라운드 후 빈 줄 출력 + 'pobi : --', + 'woni : ', + '', // 두 번째 라운드 후 빈 줄 출력 + 'pobi : ---', + 'woni : ', + '', // 세 번째 라운드 후 빈 줄 출력 + '최종 우승자 : pobi', + ], +}; + +const TWO_WINNERS_TEST_CASE = { + inputs: ['pobi,woni', '1'], + logs: ['pobi : -', 'woni : -', '최종 우승자 : pobi, woni'], +}; + +const EXCEPTION_TEST_CASES = [ + { + inputs: ['pobi'], + expectedError: INVALID_CAR_NAMES_LENGTH, + }, + { + inputs: ['pobi,moon,'], + expectedError: INVALID_CAR_NAME_LENGTH, + }, + { + inputs: ['pobicar,moon'], + expectedError: INVALID_CAR_NAME_LENGTH, + }, + { + inputs: ['pobi,pobi'], + expectedError: DUPLICATE_CAR_NAMES, + }, + { + inputs: ['pobi,woni', '0'], + expectedError: INVALID_TRY_COUNT, + }, + { + inputs: ['pobi, woni', '-1'], + expectedError: INVALID_TRY_COUNT, + }, + { + inputs: ['pobi,woni ', 'a'], + expectedError: INVALID_TRY_COUNT, + }, +]; + +export { + VARIOUS_NAME_TEST_CASES, + ROUND_TEST_CASE, + TWO_WINNERS_TEST_CASE, + EXCEPTION_TEST_CASES, +}; diff --git a/src/App.js b/src/App.js index 091aa0a5d..68fc9ed4d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,50 @@ +import { userInputCarNames, userInputTryCount } from './utils/getInputUtils.js'; +import { splitAndRemoveWhitespace } from './utils/carNameUtils.js'; +import { + printRoundStatus, + printToStartGame, + printWinners, +} from './utils/printUtils.js'; +import { validateCarNames, validateTryCount } from './validate/validateion.js'; +import { createCarObjectsFromNames } from './createCarObjects.js'; +import { moveCars } from './gameLogic.js'; +import { + getMaxPosition, + filterCarsByMaxPosition, + extractCarNames, +} from './utils/raceResultUtils.js'; + class App { - async run() {} + async run() { + try { + const inputCarNames = await userInputCarNames(); + const processedCarNames = splitAndRemoveWhitespace(inputCarNames); + + validateCarNames(processedCarNames); + + const cars = createCarObjectsFromNames(processedCarNames); + + const inputTryCount = await userInputTryCount(); + + const tryCount = Number(inputTryCount); + validateTryCount(tryCount); + + printToStartGame(); + for (let i = 0; i < tryCount; i++) { + moveCars(cars); + printRoundStatus(cars); + } + + const maxPosition = getMaxPosition(cars); + + const result = filterCarsByMaxPosition(cars, maxPosition); + + const winners = extractCarNames(result); + printWinners(winners); + } catch (error) { + throw error; + } + } } export default App; diff --git a/src/Car.js b/src/Car.js new file mode 100644 index 000000000..556995d23 --- /dev/null +++ b/src/Car.js @@ -0,0 +1,12 @@ +class Car { + constructor(name) { + this.name = name; + this.position = 0; + } + + move() { + this.position += 1; + } +} + +export { Car }; diff --git a/src/constants/errorMessages.js b/src/constants/errorMessages.js new file mode 100644 index 000000000..6c950abd7 --- /dev/null +++ b/src/constants/errorMessages.js @@ -0,0 +1,9 @@ +const ERROR_MESSAGE = { + INVALID_CAR_NAMES_LENGTH: '[ERROR] 자동차는 2개 이상 입력해야 합니다.', + INVALID_CAR_NAME_LENGTH: + '[ERROR] 자동차 이름은 1글자 이상 5글자 이하로 입력해야 합니다.', + DUPLICATE_CAR_NAMES: '[ERROR] 중복되는 자동차 이름은 입력할 수 없습니다.', + INVALID_TRY_COUNT: '[ERROR] 시도할 횟수는 양의 정수여야 합니다.', +}; + +export { ERROR_MESSAGE }; diff --git a/src/createCarObjects.js b/src/createCarObjects.js new file mode 100644 index 000000000..50aa8bff6 --- /dev/null +++ b/src/createCarObjects.js @@ -0,0 +1,7 @@ +import { Car } from './Car.js'; + +const createCarObjectsFromNames = (carNames) => { + return carNames.map((name) => new Car(name)); +}; + +export { createCarObjectsFromNames }; diff --git a/src/gameLogic.js b/src/gameLogic.js new file mode 100644 index 000000000..a8855c653 --- /dev/null +++ b/src/gameLogic.js @@ -0,0 +1,16 @@ +import { Random } from '@woowacourse/mission-utils'; + +const moveOnFourOrOver = () => { + const randomNumber = Random.pickNumberInRange(0, 9); + return randomNumber >= 4; +}; + +const moveCars = (cars) => { + for (const car of cars) { + if (moveOnFourOrOver()) { + car.move(); + } + } +}; + +export { moveCars }; diff --git a/src/utils/carNameUtils.js b/src/utils/carNameUtils.js new file mode 100644 index 000000000..8c5456174 --- /dev/null +++ b/src/utils/carNameUtils.js @@ -0,0 +1,14 @@ +const removeWhitespace = (input) => { + return input.map((name) => name.trim()); +}; + +const splitCarNames = (input) => { + return input.split(','); +}; + +const splitAndRemoveWhitespace = (input) => { + const splitNames = splitCarNames(input); + return removeWhitespace(splitNames); +}; + +export { splitAndRemoveWhitespace }; diff --git a/src/utils/getInputUtils.js b/src/utils/getInputUtils.js new file mode 100644 index 000000000..3208cbfb4 --- /dev/null +++ b/src/utils/getInputUtils.js @@ -0,0 +1,17 @@ +import { Console } from '@woowacourse/mission-utils'; + +const getUserInput = async (message) => { + return await Console.readLineAsync(message); +}; + +const userInputCarNames = async () => { + return await getUserInput( + '경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n' + ); +}; + +const userInputTryCount = async () => { + return await getUserInput('시도할 횟수는 몇 회인가요?\n'); +}; + +export { getUserInput, userInputCarNames, userInputTryCount }; diff --git a/src/utils/printUtils.js b/src/utils/printUtils.js new file mode 100644 index 000000000..fcd733856 --- /dev/null +++ b/src/utils/printUtils.js @@ -0,0 +1,22 @@ +import { Console } from '@woowacourse/mission-utils'; + +const printMessage = (message) => { + Console.print(message); +}; + +const printRoundStatus = (cars) => { + for (const car of cars) { + printMessage(`$${car.name} : ${'-'.repeat(car.position)}`); + } + printMessage(''); +}; + +const printToStartGame = () => { + printMessage('\n실행 결과'); +}; + +const printWinners = (winners) => { + printMessage(`최종 우승자 : ${winners.join(', ')}`); +}; + +export { printMessage, printRoundStatus, printToStartGame, printWinners }; diff --git a/src/utils/raceResultUtils.js b/src/utils/raceResultUtils.js new file mode 100644 index 000000000..d6dc8a383 --- /dev/null +++ b/src/utils/raceResultUtils.js @@ -0,0 +1,13 @@ +const getMaxPosition = (cars) => { + return Math.max(...cars.map((car) => car.position)); +}; + +const filterCarsByMaxPosition = (cars, maxPosition) => { + return cars.filter((car) => car.position === maxPosition); +}; + +const extractCarNames = (cars) => { + return cars.map((car) => car.name); +}; + +export { getMaxPosition, filterCarsByMaxPosition, extractCarNames }; diff --git a/src/validate/validateion.js b/src/validate/validateion.js new file mode 100644 index 000000000..9164aa96e --- /dev/null +++ b/src/validate/validateion.js @@ -0,0 +1,40 @@ +import { ERROR_MESSAGE } from '../constants/errorMessages.js'; + +const { + INVALID_CAR_NAMES_LENGTH, + INVALID_CAR_NAME_LENGTH, + DUPLICATE_CAR_NAMES, + INVALID_TRY_COUNT, +} = ERROR_MESSAGE; + +const validateCarNameListLength = (carNames) => { + if (carNames.length < 2) { + throw new Error(INVALID_CAR_NAMES_LENGTH); + } +}; + +const validateCarNameLength = (carNames) => { + if (carNames.some((name) => name.length < 1 || name.length > 5)) { + throw new Error(INVALID_CAR_NAME_LENGTH); + } +}; + +const validateDuplicateCarNames = (carNames) => { + if (new Set(carNames).size !== carNames.length) { + throw new Error(DUPLICATE_CAR_NAMES); + } +}; + +const validateCarNames = (carNames) => { + validateCarNameListLength(carNames); + validateCarNameLength(carNames); + validateDuplicateCarNames(carNames); +}; + +const validateTryCount = (tryCount) => { + if (isNaN(tryCount) || tryCount <= 0 || !Number.isInteger(tryCount)) { + throw new Error(INVALID_TRY_COUNT); + } +}; + +export { validateCarNames, validateTryCount };