diff --git a/README.md b/README.md index e078fd41..f15cf85f 100644 --- a/README.md +++ b/README.md @@ -1 +1,38 @@ # javascript-racingcar-precourse +# 2주차 - 문자열 덧셈 계산기 + +## ⚙️ 간단한 프로젝트 실행 흐름 +1. 안내 문구를 출력한다. +2. 자동차 이름을 입력 받는다. +3. 시도 횟수를 입력 받는다. +4. 입력 문자열을 parser로 분리하고 변환하다. +5. validator로 이름과 횟수를 검증한다. +6. 경주 시뮬레이션을 수행하고 차수별 결과를 출력한다. +7. 최종 우승자(동점 허용)를 계산하여 출력한다. +8. 잘못된 입력 시 [ERROR]로 시작하는 메시지와 함께 Error를 던지고 종료한다. + +## 🔧 구현할 기능 목록 + +### 1. util +- parser (문자열 -> 자료형 변환) + - [X] splitNames - 문자열을 쉼표(,)로 분리하고 trim() 적용 + - [X] toInteger - 문자열을 정수로 변환 +- validator (입력 규칙 검증) + - [X] validateNames - 이름 유효성 검증 + - [X] validateCount - 시도 횟수 검증 + +### 2. domain +- [X] Car 클래스 생성 +- [X] Race 클래스 생성 + +### 3. service +- [X] GameService 구현 + +### 4. io (input/output) +- [X] InputView +- [X] OutputView + +### 1. App.js (입출력과 전체 흐름 제어) +- [X] 모든 모듈 연동 +- [X] App.run() 실행 로직 완성 +- [ ] `ApplicationTest.js` 테스트 확인 \ No newline at end of file diff --git a/__tests__/Car.test.js b/__tests__/Car.test.js new file mode 100644 index 00000000..7ade9b49 --- /dev/null +++ b/__tests__/Car.test.js @@ -0,0 +1,25 @@ +import Car from '../src/domain/Car.js'; + +describe('Car 클래스 테스트', () => { + let car; + const CAR_NAME = 'testCar'; + + beforeEach(() => { + car = new Car(CAR_NAME); + }); + + test('constructor: new Car(name)로 생성 시 이름이 저장되고 위치는 0이다.', () => { + // then + expect(car.getName()).toBe(CAR_NAME); + expect(car.getPosition()).toBe(0); + }); + + test('move: move() 메서드를 여러 번 호출하면 position이 누적된다.', () => { + // when + car.move(); + car.move(); + car.move(); + // then + expect(car.getPosition()).toBe(3); + }); +}); diff --git a/__tests__/InputValidator.test.js b/__tests__/InputValidator.test.js new file mode 100644 index 00000000..55778b57 --- /dev/null +++ b/__tests__/InputValidator.test.js @@ -0,0 +1,31 @@ +import InputValidator from '../src/util/InputValidator.js'; +import * as Messages from '../src/constants/ErrorMessages.js'; + +describe('InputValidator 유틸 테스트', () => { + // validateNames 테스트 + describe('validateNames', () => { + test.each([ + [['pobi', 'woni', 'jun']], + ])('유효한 이름 배열(%p)은 에러를 던지지 않는다.', (names) => { + expect(() => InputValidator.validateNames(names)).not.toThrow(); + }); + + const nameError = new Error(Messages.ERROR_INVALID_NAME); + test.each([ + [['pobi', 'abcdef']], + ])('유효하지 않은 이름 배열(%p)은 에러를 던진다.', (names) => { + expect(() => InputValidator.validateNames(names)).toThrow(nameError); + }); + }); + + // validateTryCount 테스트 + describe('validateTryCount', () => { + const countError = new Error(Messages.ERROR_INVALID_TRY_COUNT); + test.each([ + [0], + ])('유효하지 않은 시도 횟수(%p)는 에러를 던진다.', (count) => { + expect(() => InputValidator.validateTryCount(count)).toThrow(countError); + }); + }); +}); + diff --git a/__tests__/Parser.test.js b/__tests__/Parser.test.js new file mode 100644 index 00000000..8d8493e8 --- /dev/null +++ b/__tests__/Parser.test.js @@ -0,0 +1,25 @@ +import Parser from '../src/util/Parser.js'; + +describe('Parser 유틸 테스트', () => { + // splitNames 테스트 + describe('splitNames', () => { + test.each([ + { input: ' pobi , woni ', expected: ['pobi', 'woni'] }, + ])( + "splitNames('$input')는 $expected를 반환해야 한다.", + ({ input, expected }) => { + expect(Parser.splitNames(input)).toEqual(expected); + }, + ); + }); + + // toInteger 테스트 + describe('toInteger', () => { + test.each([ + { input: 'abc' }, + ])("toInteger('$input')는 NaN을 반환해야 한다.", ({ input }) => { + expect(Parser.toInteger(input)).toBeNaN(); + }); + }); +}); + diff --git a/__tests__/Race.test.js b/__tests__/Race.test.js new file mode 100644 index 00000000..5788085c --- /dev/null +++ b/__tests__/Race.test.js @@ -0,0 +1,39 @@ +import Race from '../src/domain/Race.js'; +import Car from '../src/domain/Car.js'; + +describe('Race 클래스 테스트', () => { + let cars; + + beforeEach(() => { + cars = [new Car('pobi'), new Car('woni'), new Car('jun')]; + }); + + test('tickOnce: RNG가 모두 4 이상이면 모든 차가 전진한다.', () => { + // given + const alwaysMoveRNG = () => 5; + const race = new Race(cars, alwaysMoveRNG); + + // when + race.tickOnce(); + + // then + cars.forEach((car) => { + expect(car.getPosition()).toBe(1); + }); + }); + + test('tickOnce: RNG가 모두 4 미만이면 모든 차가 멈춘다.', () => { + // given + const alwaysStopRNG = () => 3; + const race = new Race(cars, alwaysStopRNG); + + // when + race.tickOnce(); + + // then + cars.forEach((car) => { + expect(car.getPosition()).toBe(0); + }); + }); +}); + diff --git a/src/App.js b/src/App.js index 091aa0a5..3464699b 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,21 @@ +import InputView from './io/InputView.js'; +import Parser from './util/Parser.js'; +import InputValidator from './util/InputValidator.js'; +import GameService from './service/GameService.js'; + class App { - async run() {} + async run() { + const rawNames = await InputView.readCarNames(); + const rawTryCount = await InputView.readTryCount(); + + const names = Parser.splitNames(rawNames); + const tryCount = Parser.toInteger(rawTryCount); + + InputValidator.validateNames(names); + InputValidator.validateTryCount(tryCount); + + await GameService.play({ names, tryCount }); + } } export default App; diff --git a/src/constants/ErrorMessages.js b/src/constants/ErrorMessages.js new file mode 100644 index 00000000..7f1496d9 --- /dev/null +++ b/src/constants/ErrorMessages.js @@ -0,0 +1,3 @@ +const ERROR_INVALID_NAME = "[ERROR] 유효하지 않은 자동차 이름입니다."; +const ERROR_INVALID_TRY_COUNT = "[ERROR] 유효하지 않은 시도 횟수입니다."; +export { ERROR_INVALID_NAME, ERROR_INVALID_TRY_COUNT }; \ No newline at end of file diff --git a/src/domain/Car.js b/src/domain/Car.js new file mode 100644 index 00000000..6790c776 --- /dev/null +++ b/src/domain/Car.js @@ -0,0 +1,21 @@ +export default class Car { + #name; + #position; + + constructor(name) { + this.#name = name; + this.#position = 0; + } + + move() { + this.#position += 1; + } + + getName() { + return this.#name; + } + + getPosition() { + return this.#position; + } +} \ No newline at end of file diff --git a/src/domain/Race.js b/src/domain/Race.js new file mode 100644 index 00000000..57a1bf64 --- /dev/null +++ b/src/domain/Race.js @@ -0,0 +1,37 @@ +import { MissionUtils } from '@woowacourse/mission-utils'; + +const RANGE_MIN = 0; +const RANGE_MAX = 9; +const MOVE_THRESHOLD = 4; + +export default class Race { + #cars; + #rng; // Random Number Generator + + constructor(cars, rng) { + this.#cars = cars; + this.#rng = () => MissionUtils.Random.pickNumberInRange(RANGE_MIN, RANGE_MAX); + + if (typeof rng === 'function') { + this.#rng = rng; + } + } + + tickOnce() { + for (let i = 0; i < this.#cars.length; i += 1) { + this.#tryMoveCar(this.#cars[i]); + } + return this.#cars.map((car) => ({ + name: car.getName(), + position: car.getPosition(), + })); + } + + #tryMoveCar(car) { + const value = this.#rng(); + + if (value >= MOVE_THRESHOLD) { + car.move(); + } + } +} \ No newline at end of file diff --git a/src/io/InputView.js b/src/io/InputView.js new file mode 100644 index 00000000..01c5c3cb --- /dev/null +++ b/src/io/InputView.js @@ -0,0 +1,13 @@ +import {MissionUtils} from '@woowacourse/mission-utils'; + +export default class InputView { + static readCarNames() { + const prompt = '경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n'; + return MissionUtils.Console.readLineAsync(prompt); + } + + static readTryCount() { + const prompt = '시도할 회수는 몇 회인가요?\n'; + return MissionUtils.Console.readLineAsync(prompt); + } +} \ No newline at end of file diff --git a/src/io/OutputView.js b/src/io/OutputView.js new file mode 100644 index 00000000..8d60c4ef --- /dev/null +++ b/src/io/OutputView.js @@ -0,0 +1,20 @@ +import {MissionUtils} from '@woowacourse/mission-utils'; + +export default class OutputView { + static printExecutionHeader() { + MissionUtils.Console.print('\n실행 결과'); + } + + static printRound(roundResult) { + for (let i = 0; i < roundResult.length; i += 1) { + const { name, position } = roundResult[i]; + const dashes = '-'.repeat(position); + MissionUtils.Console.print(`${name} : ${dashes}`); + } + MissionUtils.Console.print(''); + } + + static printWinners(winners) { + MissionUtils.Console.print(`최종 우승자 : ${winners.join(', ')}`); + } +} \ No newline at end of file diff --git a/src/service/GameService.js b/src/service/GameService.js new file mode 100644 index 00000000..b2adff81 --- /dev/null +++ b/src/service/GameService.js @@ -0,0 +1,38 @@ +import OutputView from "../io/OutputView.js"; +import Car from "../domain/Car.js"; +import Race from "../domain/Race.js"; + +export default class GameService { + static async play({ names, tryCount }) { + const cars = names.map((name) => new Car(name)); + const race = new Race(cars); + + OutputView.printExecutionHeader(); + + for (let i = 0; i < tryCount; i += 1) { + const roundResult = race.tickOnce(); + OutputView.printRound(roundResult); + } + + const winners = GameService.#computeWinners(cars); + OutputView.printWinners(winners); + } + + static #computeWinners(cars) { + const maxPosition = GameService.#findMaxPosition(cars); + const winners = GameService.#findWinners(cars, maxPosition); + return winners; + } + + // 최대 위치 찾기 + static #findMaxPosition(cars) { + const positions = cars.map((car) => car.getPosition()); + return Math.max(...positions); + } + + // 최대 위치와 일치하는 우승자 목록 반환 + static #findWinners(cars, maxPosition) { + const winnerCars = cars.filter((car) => car.getPosition() === maxPosition); + return winnerCars.map((car) => car.getName()); + } +} diff --git a/src/util/InputValidator.js b/src/util/InputValidator.js new file mode 100644 index 00000000..55769643 --- /dev/null +++ b/src/util/InputValidator.js @@ -0,0 +1,39 @@ +import { + ERROR_INVALID_NAME, + ERROR_INVALID_TRY_COUNT, +} from "../constants/ErrorMessages.js"; + +export default class InputValidator { + static validateNames(names) { + if (!Array.isArray(names) || names.length === 0) { + throw new Error(ERROR_INVALID_NAME); + } + + names.forEach((name) => { + this._validateSingleName(name); + }); + } + + static _validateSingleName(name) { + if (typeof name !== 'string') { + throw new Error(ERROR_INVALID_NAME); + } + const trimmed = name.trim(); + if (trimmed.length === 0) { + throw new Error(ERROR_INVALID_NAME); + } + if (trimmed.length > 5) { + throw new Error(ERROR_INVALID_NAME); + } + } + + static validateTryCount(count) { + if (!Number.isInteger(count)) { + throw new Error(ERROR_INVALID_TRY_COUNT); + } + + if (count < 1) { + throw new Error(ERROR_INVALID_TRY_COUNT); + } + } +} \ No newline at end of file diff --git a/src/util/Parser.js b/src/util/Parser.js new file mode 100644 index 00000000..6a32a185 --- /dev/null +++ b/src/util/Parser.js @@ -0,0 +1,33 @@ +export default class Parser { + static splitNames(raw) { + if (typeof raw !== 'string') { + return []; + } + return raw.split(',').map(name => name.trim()); + } + + static _parseNumberInput(num) { + if (Number.isInteger(num)) { + return num; + } + return NaN; + } + + static _parseStringInput(str) { + const n = Number.parseInt(str, 10); + if (Number.isNaN(n)) { + return NaN; + } + return n; + } + + static toInteger(raw) { + if (typeof raw === 'number') { + return this._parseNumberInput(raw); + } + if (typeof raw === 'string') { + return this._parseStringInput(raw); + } + return NaN; + } +} \ No newline at end of file