Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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` 테스트 확인
25 changes: 25 additions & 0 deletions __tests__/Car.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
31 changes: 31 additions & 0 deletions __tests__/InputValidator.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

25 changes: 25 additions & 0 deletions __tests__/Parser.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
});

39 changes: 39 additions & 0 deletions __tests__/Race.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
});

18 changes: 17 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions src/constants/ErrorMessages.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const ERROR_INVALID_NAME = "[ERROR] 유효하지 않은 자동차 이름입니다.";
const ERROR_INVALID_TRY_COUNT = "[ERROR] 유효하지 않은 시도 횟수입니다.";
export { ERROR_INVALID_NAME, ERROR_INVALID_TRY_COUNT };
21 changes: 21 additions & 0 deletions src/domain/Car.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
37 changes: 37 additions & 0 deletions src/domain/Race.js
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
13 changes: 13 additions & 0 deletions src/io/InputView.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
20 changes: 20 additions & 0 deletions src/io/OutputView.js
Original file line number Diff line number Diff line change
@@ -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(', ')}`);
}
}
38 changes: 38 additions & 0 deletions src/service/GameService.js
Original file line number Diff line number Diff line change
@@ -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());
}
}
39 changes: 39 additions & 0 deletions src/util/InputValidator.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Loading