diff --git a/README.md b/README.md index e078fd41..f88ff3f1 100644 --- a/README.md +++ b/README.md @@ -1 +1,69 @@ -# javascript-racingcar-precourse +# 자동차 경주 +자동차 이름과 시도 횟수를 입력 받아 자동차 게임을 진행한다. + +## 기능 +### 입력 +- [x] 경주할 자동차 이름(이름은 쉼표(,) 기준으로 구분) 입력 +- [x] 시도할 횟수 입력 + +### 파싱 +- [x] 자동차 이름 입력값을 쉼표(,) 기준으로 나누어 배열로 변환 +- [x] 각 이름의 공백 제거 + +### 유효성 검사 +- [x] 이름 5자 이하 +- [x] 이름 공백 불가능 +- [x] 이름 중복 불가능 +- [x] 시도 횟수는 0보다 큰 정수 + +### 게임 진행 +- [x] 자동차 생성 +- [x] 0에서 9 사이에서 무작위 값을 구한 후 무작위 값이 4 이상인 경우 자동차 전진 +- [x] 시도 횟수만큼 게임 진행 +- [x] 우승자 선출 + +### 출력 +- [x] 라운드별 실행 결과 출력 +- [x] 게임 종료 후 우승자 안내 문구 출력 + +### 테스트 +- [x] validator +- [x] car +- [x] RacingGame +- [x] output + +## 프로젝트 구조 +``` +src/ +├── App.js # 프로그램 실행 흐름 관리 (입력 → 검증 → 게임 → 출력) +├── index.js # 프로그램 실행 진입점 +├── Car.js # 자동차 클래스 (이름, 위치, 이동 로직) +├── RacingGame.js # 게임 진행 및 우승자 계산 +├── input.js # 사용자 입력 처리 +├── output.js # 게임 결과 및 우승자 출력 +├── validator.js # 입력값 유효성 검사 +└── utils/ + └── randomUtils.js # 랜덤 숫자 생성 (0~9) +__tests__/ # 단위 테스트 모음 +``` + +## 실행 결과 +```bash +경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분) +pobi,woni,jun +시도할 횟수는 몇 회인가요? +3 +pobi : +woni : - +jun : - + +pobi : - +woni : -- +jun : - + +pobi : - +woni : --- +jun : - + +최종 우승자 : woni +``` \ No newline at end of file diff --git a/__tests__/RacingGame.test.js b/__tests__/RacingGame.test.js new file mode 100644 index 00000000..ce34b0ba --- /dev/null +++ b/__tests__/RacingGame.test.js @@ -0,0 +1,67 @@ +import { RacingGame } from "../src/RacingGame.js"; +import * as randomUtils from "../src/utils/randomUtils.js"; +import { Car } from "../src/Car.js"; + +describe("RacingGame 클래스", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("랜덤 값이 4 이상이면 자동차가 전진한다.", () => { + const carNames = ["pobi", "woni"]; + const game = new RacingGame(carNames); + + jest.spyOn(randomUtils, "getRandomNumber").mockReturnValue(7); + game.playRound(); + + game.cars.forEach((car) => { + expect(car.position).toBe(1); + }); + }); + + test("랜덤 값이 4 미만이면 자동차가 전진하지 않는다.", () => { + const carNames = ["pobi", "woni"]; + const game = new RacingGame(carNames); + + jest.spyOn(randomUtils, "getRandomNumber").mockReturnValue(2); + + game.playRound(); + game.cars.forEach((car) => { + expect(car.position).toBe(0); + }); + }); + + describe("우승자 선출", () => { + test("가장 멀리 간 자동차가 단독 우승자가 된다.", () => { + const game = new RacingGame(["pobi", "woni", "jun"]); + game.cars = [ + new Car("pobi"), + new Car("woni"), + new Car("jun"), + ]; + + game.cars[0].position = 2; + game.cars[1].position = 5; + game.cars[2].position = 3; + + const winners = game.getWinners(); + expect(winners).toEqual(["woni"]); + }); + + test("가장 멀리 간 자동차가 여러 대면 공동 우승자가 된다.", () => { + const game = new RacingGame(["pobi", "woni", "jun"]); + game.cars = [ + new Car("pobi"), + new Car("woni"), + new Car("jun"), + ]; + + game.cars[0].position = 4; + game.cars[1].position = 4; + game.cars[2].position = 2; + + const winners = game.getWinners(); + expect(winners).toEqual(["pobi", "woni"]); + }); + }); +}); \ No newline at end of file diff --git a/__tests__/car.test.js b/__tests__/car.test.js new file mode 100644 index 00000000..810ab1b6 --- /dev/null +++ b/__tests__/car.test.js @@ -0,0 +1,36 @@ +import { Car } from "../src/Car.js"; + +describe("Car 클래스", () => { + test("자동차는 이름과 초기 위치를 가진다", () => { + const car = new Car("pobi"); + + expect(car.name).toBe("pobi"); + expect(car.position).toBe(0); + }); + + test("randomValue가 4인 경우 canMove는 true를 반환한다.", () => { + const car = new Car("pobi"); + expect(car.canMove(4)).toBe(true); + }); + + test("randomValue가 3인 경우 canMove는 false를 반환한다.", () => { + const car = new Car("pobi"); + expect(car.canMove(3)).toBe(false); + }); + + test("move()를 호출하면 position이 1 증가한다", () => { + const car = new Car("pobi"); + car.move(); + + expect(car.position).toBe(1); + }); + + test("move()를 여러 번 호출하면 그 횟수만큼 position이 증가한다", () => { + const car = new Car("pobi"); + car.move(); + car.move(); + car.move(); + + expect(car.position).toBe(3); + }); +}); \ No newline at end of file diff --git a/__tests__/output.test.js b/__tests__/output.test.js new file mode 100644 index 00000000..6e606e5c --- /dev/null +++ b/__tests__/output.test.js @@ -0,0 +1,47 @@ +import { printRoundResult, printWinners } from "../src/output.js"; +import { Console } from "@woowacourse/mission-utils"; + +describe("printRoundResult", () => { + beforeEach(() => { + jest.spyOn(Console, "print").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("자동차의 현재 위치를 '-'로 출력한다.", () => { + const cars = [ + { name: "pobi", position: 2 }, + { name: "crong", position: 3 }, + ]; + + printRoundResult(cars); + + expect(Console.print).toHaveBeenCalledWith("pobi : --"); + expect(Console.print).toHaveBeenCalledWith("crong : ---"); + expect(Console.print).toHaveBeenCalledWith(""); + }); +}); + +describe("printWinners", () => { + beforeEach(() => { + jest.spyOn(Console, "print").mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test("우승자가 한 명일 경우 이름을 출력한다.", () => { + const winners = ["pobi"]; + printWinners(winners); + expect(Console.print).toHaveBeenCalledWith("최종 우승자 : pobi"); + }); + + test("우승자가 여러 명일 경우 쉼표로 구분해 출력한다.", () => { + const winners = ["pobi", "woni", "jun"]; + printWinners(winners); + expect(Console.print).toHaveBeenCalledWith("최종 우승자 : pobi, woni, jun"); + }); +}); \ No newline at end of file diff --git a/__tests__/validator.test.js b/__tests__/validator.test.js new file mode 100644 index 00000000..f5e5d7f0 --- /dev/null +++ b/__tests__/validator.test.js @@ -0,0 +1,53 @@ +import { validateCarNames, validateTryCount, MAX_CAR_NAME_LENGTH } from "../src/validator.js"; + +describe("자동차 이름 유효성 검사", () => { + test("이름이 비어있으면 에러를 발생시킨다", () => { + expect(() => validateCarNames(["", "pobi"])).toThrow( + "[ERROR] 이름에 공백이 포함되어 있습니다." + ); + }); + + test(`이름이 ${MAX_CAR_NAME_LENGTH}자를 초과하면 에러를 발생시킨다`, () => { + const overLengthName = "a".repeat(MAX_CAR_NAME_LENGTH + 1); + expect(() => validateCarNames([overLengthName, "jun"])).toThrow( + `[ERROR] 자동차 이름은 ${MAX_CAR_NAME_LENGTH}자 이하만 가능합니다.` + ); + }); + + test("이름이 중복되면 에러를 발생시킨다", () => { + expect(() => validateCarNames(["pobi", "woni", "pobi"])).toThrow( + "[ERROR] 자동차 이름은 중복될 수 없습니다." + ); + }); + + test("모든 이름이 조건을 만족하면 통과한다", () => { + expect(() => validateCarNames(["pobi", "jun", "woni"])).not.toThrow(); + }); +}); + +describe("시도 횟수 유효성 검사", () => { + test("숫자가 아닌 값을 입력하면 에러를 발생시킨다", () => { + expect(() => validateTryCount("abc")).toThrow( + "[ERROR] 시도 횟수는 숫자여야 합니다." + ); + }); + + test("소수 값을 입력하면 에러를 발생시킨다", () => { + expect(() => validateTryCount("3.5")).toThrow( + "[ERROR] 시도 횟수는 정수여야 합니다." + ); + }); + + test(`0 이하의 값을 입력하면 에러를 발생시킨다`, () => { + expect(() => validateTryCount("0")).toThrow( + `[ERROR] 시도 횟수는 0보다 커야 합니다.` + ); + expect(() => validateTryCount("-2")).toThrow( + `[ERROR] 시도 횟수는 0보다 커야 합니다.` + ); + }); + + test(`0보다 큰 양의 정수를 입력하면 통과한다`, () => { + expect(() => validateTryCount("5")).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/src/App.js b/src/App.js index 091aa0a5..57c40bde 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,23 @@ +import { readCarNames, readTryCount } from "./input.js"; +import { validateCarNames, validateTryCount } from "./validator.js"; +import { RacingGame } from "./RacingGame.js"; +import { printWinners } from "./output.js"; + class App { - async run() {} + async run() { + const carNamesRaw = await readCarNames(); + const carNames = carNamesRaw.split(",").map((name) => name.trim()); + validateCarNames(carNames); + + const tryCount = await readTryCount(); + validateTryCount(tryCount); + + const game = new RacingGame(carNames); + game.play(tryCount); + + const winners = game.getWinners(); + printWinners(winners); + } } export default App; diff --git a/src/Car.js b/src/Car.js new file mode 100644 index 00000000..c81f0b7f --- /dev/null +++ b/src/Car.js @@ -0,0 +1,14 @@ +export class Car { + constructor(name) { + this.name = name; + this.position = 0; + } + + canMove(randomValue){ + return randomValue >= 4; + } + + move() { + this.position += 1; + } +} \ No newline at end of file diff --git a/src/RacingGame.js b/src/RacingGame.js new file mode 100644 index 00000000..4e081f3a --- /dev/null +++ b/src/RacingGame.js @@ -0,0 +1,32 @@ +import { Car } from "./Car.js"; +import { getRandomNumber } from "./utils/randomUtils.js"; +import { printRoundResult } from "./output.js"; + +export class RacingGame { + constructor(carNames) { + this.cars = carNames.map((name) => new Car(name)); + } + + playRound() { + this.cars.forEach((car) => { + const randomValue = getRandomNumber(); + if (car.canMove(randomValue)) { + car.move(); + } + }); + printRoundResult(this.cars); + } + + play(tryCount) { + for (let i = 0; i car.position)); + return this.cars + .filter((car) => car.position === max) + .map((car) => car.name); + } +} \ No newline at end of file diff --git a/src/input.js b/src/input.js new file mode 100644 index 00000000..26880620 --- /dev/null +++ b/src/input.js @@ -0,0 +1,15 @@ +import { Console } from "@woowacourse/mission-utils"; + +export async function readCarNames() { + const input = await Console.readLineAsync( + "경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)\n" + ); + return input; +} + +export async function readTryCount() { + const input = await Console.readLineAsync( + "시도할 횟수는 몇 회인가요?\n" + ); + return input; +} \ No newline at end of file diff --git a/src/output.js b/src/output.js new file mode 100644 index 00000000..f087de28 --- /dev/null +++ b/src/output.js @@ -0,0 +1,13 @@ +import { Console } from "@woowacourse/mission-utils"; + +export function printRoundResult(cars) { + cars.forEach((car) => { + const positionBar = "-".repeat(car.position); + Console.print(`${car.name} : ${positionBar}`); + }); + Console.print(""); +} + +export function printWinners(winners) { + Console.print(`최종 우승자 : ${winners.join(", ")}`); +} \ No newline at end of file diff --git a/src/utils/randomUtils.js b/src/utils/randomUtils.js new file mode 100644 index 00000000..b168b734 --- /dev/null +++ b/src/utils/randomUtils.js @@ -0,0 +1,5 @@ +import { Random } from "@woowacourse/mission-utils"; + +export function getRandomNumber() { + return Random.pickNumberInRange(0,9); +} \ No newline at end of file diff --git a/src/validator.js b/src/validator.js new file mode 100644 index 00000000..95ab3c30 --- /dev/null +++ b/src/validator.js @@ -0,0 +1,32 @@ +export const MAX_CAR_NAME_LENGTH = 5; + +export function validateCarNames(names) { + if (names.some((name) => name.length === 0)) { + throw new Error("[ERROR] 이름에 공백이 포함되어 있습니다."); + } + + if (names.some((name) => name.trim().length > MAX_CAR_NAME_LENGTH)) { + throw new Error(`[ERROR] 자동차 이름은 ${MAX_CAR_NAME_LENGTH}자 이하만 가능합니다.`); + } + + const uniqueNames = new Set(names); + if (uniqueNames.size !== names.length) { + throw new Error("[ERROR] 자동차 이름은 중복될 수 없습니다."); + } +} + +export function validateTryCount(count) { + const num = Number(count); + + if (Number.isNaN(num)) { + throw new Error("[ERROR] 시도 횟수는 숫자여야 합니다."); + } + + if (!Number.isInteger(num)) { + throw new Error("[ERROR] 시도 횟수는 정수여야 합니다."); + } + + if (num<=0) { + throw new Error("[ERROR] 시도 횟수는 0보다 커야 합니다."); + } +}