Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[자동차 경주] 유아름 미션 제출합니다. #446

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1 +1,35 @@
# javascript-racingcar-precourse

자동차 경주 게임 구현

## 기능 목록

### 유저 입력 처리

- [x] 사용자는 경주할 자동차에 각각 이름(name)을 부여할 수 있다.
- [x] 자동차 이름 입력시 n대의 자동차 이름을 입력하며, 이를 통해 경주에 참여하는 자동차 수를 정한다.
- [x] 자동차 이름은 쉼표(,)를 기준으로 구분한다.
- [x] 사용자에게 몇 번의 이동을 할 것인지 라운드 횟수(total round)를 입력할 수 있다.

### 자동차 경주 실행 및 실행 결과 출력

- [x] 사용자가 입력한 이동 횟수(total round) 만큼 자동차 경주 함수를 실행한다.
- [x] 각 차수(round)마다 n대의 자동차는 각각 전진 또는 정지(전진하지 않음) 할 수 있다.
- [x] 자동차는 매번 0에서 9 사이의 무작위 값을 구한 후, 무작위 값이 4 이상일 경우 전진하고 4 미만일 경우 정지한다.
- [x] 실행 결과를 출력한다. (자동차마다의 출력 문구: `${자동차 이름(name)} : ${자동차 위치(position)}`)
- [x] 자동차의 위치(position)는 '-'로 표시한다. ('-'는 1회 전진, '--'는 2회 전진)

### 우승자 계산 및 출력

- [x] 주어진 횟수 동안 가장 많이 전진한 자동차를 우승자(winner)로 출력한다. (출력 문구: `최종 우승자 : ${우승자(winner)}`)
- [x] 우승자는 한 명 이상일 수 있다.
- [x] 우승자가 여러 명일 경우 쉼표를 이용하여 구분한다.

### 에러 처리

- [x] 사용자가 잘못된 값을 입력한 경우 "[ERROR]"로 시작하는 메시지와 함께 `Error`를 발생시킨 후 애플리케이션이 종료된다.
- [x] 자동차 이름이 5자를 초과할 경우
- [x] 경주할 자동차 이름을 1개 이하로 지정한 경우
- [x] 시도할 횟수에 양의 정수 값이 아닌 값을 입력한 경우
- [x] 값을 입력하지 않은 경우
- [x] 중복된 자동차 이름 값을 가질 경우
48 changes: 48 additions & 0 deletions __tests__/Car.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Car from "../src/models/Car";
import { Random } from "@woowacourse/mission-utils";

describe("Car 클래스 테스트", () => {
test("move: Random 값이 4 이상일 때만 position이 증가한다.", () => {
const car = new Car("testCar");

jest.spyOn(Random, "pickNumberInRange").mockReturnValue(4);
car.move();
expect(car.getPosition()).toBe(1);

jest.spyOn(Random, "pickNumberInRange").mockReturnValue(3);
car.move();
expect(car.getPosition()).toBe(1);

jest.spyOn(Random, "pickNumberInRange").mockRestore();
});

test("getName: 인스턴스의 이름을 반환한다.", () => {
const car = new Car("myCar");
expect(car.getName()).toBe("myCar");
});

test("getPosition: 인스턴스의 현재 위치를 반환한다.", () => {
const car = new Car("testCar");

expect(car.getPosition()).toBe(0);

jest.spyOn(Random, "pickNumberInRange").mockReturnValue(5);
car.move();
expect(car.getPosition()).toBe(1);

jest.spyOn(Random, "pickNumberInRange").mockRestore();
});

test("getState: 인스턴스의 이름과 위치 상태('-'로 표시)를 반환한다.", () => {
const car = new Car("myCar");

expect(car.getState()).toBe("myCar : ");

jest.spyOn(Random, "pickNumberInRange").mockReturnValue(4);
car.move();
car.move();
expect(car.getState()).toBe("myCar : --");

jest.spyOn(Random, "pickNumberInRange").mockRestore();
});
});
72 changes: 72 additions & 0 deletions __tests__/Race.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Race from "../src/models/Race";
import { Console } from "@woowacourse/mission-utils";

describe("Race 클래스 테스트", () => {
let race;

beforeEach(() => {
const carNames = ["car1", "car2", "car3"];
const totalRound = 3;
race = new Race(carNames, totalRound);
});

afterEach(() => {
jest.clearAllMocks();
});

test("getWinner: 가장 멀리 이동한 자동차를 우승자로 반환한다.", () => {
race.cars[0].position = 2;
race.cars[1].position = 3;
race.cars[2].position = 1;

expect(race.getWinner()).toEqual(["car2"]);
});

test("getWinner: 두 명 이상의 우승자가 발생할 경우 쉼표(,)로 구분하여 반환한다.", () => {
race.cars[0].position = 2;
race.cars[1].position = 3;
race.cars[2].position = 3;

expect(race.getWinner()).toEqual(["car2", "car3"]);
});

test("start: 전체 레이스 결과 및 최종 우승자를 출력한다.", () => {
const printSpy = jest.spyOn(Console, "print");

jest
.spyOn(race.cars[0], "move")
.mockImplementation(() => (race.cars[0].position += 2));
jest
.spyOn(race.cars[1], "move")
.mockImplementation(() => (race.cars[1].position += 3));
jest
.spyOn(race.cars[2], "move")
.mockImplementation(() => (race.cars[2].position += 1));

race.start();

expect(printSpy).toHaveBeenCalledWith("\n실행 결과");
expect(printSpy).toHaveBeenCalledWith("최종 우승자 : car2");
});

test("runARound: 각 자동차의 이름과 이동 상태가 출력된다.", () => {
const printSpy = jest.spyOn(Console, "print");

jest
.spyOn(race.cars[0], "move")
.mockImplementation(() => (race.cars[0].position += 1));
jest
.spyOn(race.cars[1], "move")
.mockImplementation(() => (race.cars[1].position += 2));
jest
.spyOn(race.cars[2], "move")
.mockImplementation(() => (race.cars[2].position += 3));

race.runARound();

expect(printSpy).toHaveBeenCalledWith("car1 : -");
expect(printSpy).toHaveBeenCalledWith("car2 : --");
expect(printSpy).toHaveBeenCalledWith("car3 : ---");
expect(printSpy).toHaveBeenCalledWith("");
});
});
64 changes: 64 additions & 0 deletions __tests__/input.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { validateCarNames, validateRoundNumber } from "./inputValidator";
import { ERROR_MESSAGE } from "../constants/errorMessage";

describe("validateCarNames 테스트", () => {
test("유효한 자동차 이름 목록 입력시 이름 목록을 배열로 반환한다.", () => {
const carNames = "car1, car2, car3";
expect(validateCarNames(carNames)).toEqual(["car1", "car2", "car3"]);
});

test("빈 입력 시 오류를 발생시킨다.", () => {
expect(() => validateCarNames("")).toThrow(ERROR_MESSAGE.EMPTY_INPUT);
});

test("항목이 하나뿐인 경우 오류를 발생시킨다.", () => {
expect(() => validateCarNames("car1")).toThrow(
ERROR_MESSAGE.SINGLE_CAR_NAME
);
});

test("자동차 이름이 지정된 최대 길이를 초과할 때 오류를 발생시킨다.", () => {
const invalidCarNames = "car123456, car2";
expect(() => validateCarNames(invalidCarNames)).toThrow(
ERROR_MESSAGE.MAX_LENGTH_EXCEEDED
);
});

test("중복된 이름이 있을 때 오류 발생시킨다.", () => {
const duplicateNames = "car1, car2, car1";
expect(() => validateCarNames(duplicateNames)).toThrow(
ERROR_MESSAGE.DUPLICATE_NAME
);
});
});

describe("validateRoundNumber 테스트", () => {
test("유효한 totalRound 입력 시 입력한 값을 정수로 변환하여 반환한다.", () => {
expect(validateRoundNumber("5")).toBe(5);
});

test("빈 입력 시 오류를 발생시킨다.", () => {
expect(() => validateRoundNumber("")).toThrow(ERROR_MESSAGE.EMPTY_INPUT);
});

test("0 이하의 숫자를 입력할 경우 오류를 발생시킨다.", () => {
expect(() => validateRoundNumber("0")).toThrow(
ERROR_MESSAGE.INVALID_NUMBER
);
expect(() => validateRoundNumber("-3")).toThrow(
ERROR_MESSAGE.INVALID_NUMBER
);
});

test("소수 입력 시 오류를 발생시킨다.", () => {
expect(() => validateRoundNumber("3.5")).toThrow(
ERROR_MESSAGE.INVALID_NUMBER
);
});

test("숫자가 아닌 문자열 입력 시 오류를 발생시킨다.", () => {
expect(() => validateRoundNumber("abc")).toThrow(
ERROR_MESSAGE.INVALID_NUMBER
);
});
});
15 changes: 14 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import Race from "./models/Race.js";
import { getCarNames, getTotalRound } from "./utils/input.js";

class App {
async run() {}
async run() {
try {
const carNameList = await getCarNames();
const totalRound = await getTotalRound();

const race = new Race(carNameList, totalRound);
race.start();
} catch (error) {
throw new Error(`[ERROR] ${error.message}`);
}
}
}

export default App;
10 changes: 10 additions & 0 deletions src/constants/errorMessage.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const CAR_NAME_MAX_LENGTH = 5;

export const ERROR_MESSAGE = {
EMPTY_INPUT: "필수 입력 값입니다.\n항목을 입력해 주세요.\n",
SINGLE_CAR_NAME:
"자동차 경주에는 두 개 이상의 자동차 이름이 필요합니다.\n쉼표(,)를 기준으로 두 개 이상의 이름을 입력해주세요.\n",
MAX_LENGTH_EXCEEDED: `각각의 자동차 이름은 ${CAR_NAME_MAX_LENGTH}글자 이하로 이루어져야 합니다.\n`,
INVALID_NUMBER: "시도할 횟수는 1 이상의 숫자여야 합니다.\n",
DUPLICATE_NAME: "자동차 이름이 중복되지 않게 입력해 주세요.\n",
};
30 changes: 30 additions & 0 deletions src/models/Car.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Random } from "@woowacourse/mission-utils";

class Car {
constructor(name) {
this.name = name;
this.position = 0;
}

move() {
const value = Random.pickNumberInRange(0, 9);

if (value >= 4) {
this.position++;
}
}

getName() {
return this.name;
}

getPosition() {
return this.position;
}

getState() {
return `${this.name} : ${"-".repeat(this.position)}`;
}
}

export default Car;
39 changes: 39 additions & 0 deletions src/models/Race.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { Console } from "@woowacourse/mission-utils";
import Car from "./Car.js";

class Race {
constructor(carNames, totalRound) {
this.cars = carNames.map((name) => new Car(name));
this.totalRound = totalRound;
}

runARound() {
this.cars.forEach((car) => {
car.move();
Console.print(car.getState());
});

Console.print("");
}

getWinner() {
const maxDistance = Math.max(...this.cars.map((car) => car.getPosition()));
const winner = this.cars
.filter((car) => car.getPosition() === maxDistance)
.map((car) => car.getName());

return winner;
}

start() {
Console.print("\n실행 결과");

for (let i = 0; i < this.totalRound; i++) {
this.runARound();
}

Console.print(`최종 우승자 : ${this.getWinner().join(", ")}`);
}
}

export default Race;
22 changes: 22 additions & 0 deletions src/utils/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Console } from "@woowacourse/mission-utils";
import { validateCarNames, validateRoundNumber } from "./inputValidator.js";

export const getCarNames = async () => {
const carNamesInput = await Console.readLineAsync(
"경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n"
);

const carNameList = validateCarNames(carNamesInput);

return carNameList;
};

export const getTotalRound = async () => {
const totalRoundInput = await Console.readLineAsync(
"시도할 횟수는 몇 회인가요?\n"
);

const totalRound = validateRoundNumber(totalRoundInput);

return totalRound;
};
45 changes: 45 additions & 0 deletions src/utils/inputValidator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
CAR_NAME_MAX_LENGTH,
ERROR_MESSAGE,
} from "../constants/errorMessage.js";

export const validateCarNames = (carNames) => {
if (!carNames || carNames.trim() === "") {
throw new Error(ERROR_MESSAGE.EMPTY_INPUT);
}

const carNameList = carNames.split(",").map((name) => name.trim());
if (carNameList.length < 2) {
throw new Error(ERROR_MESSAGE.SINGLE_CAR_NAME);
}

carNameList.forEach((name) => {
if (name.length > CAR_NAME_MAX_LENGTH) {
throw new Error(ERROR_MESSAGE.MAX_LENGTH_EXCEEDED);
}
});

const uniqueNames = new Set(carNameList);
if (uniqueNames.size !== carNameList.length) {
throw new Error(ERROR_MESSAGE.DUPLICATE_NAME);
}

return carNameList;
};

export const validateRoundNumber = (totalRound) => {
if (!totalRound) {
throw new Error(ERROR_MESSAGE.EMPTY_INPUT);
}

const totalRoundNumber = Number(totalRound);
if (
isNaN(totalRound) ||
totalRound <= 0 ||
!Number.isInteger(totalRoundNumber)
) {
throw new Error(ERROR_MESSAGE.INVALID_NUMBER);
}

return totalRoundNumber;
};