diff --git a/README.md b/README.md index e078fd41..612b413a 100644 --- a/README.md +++ b/README.md @@ -1 +1,62 @@ # javascript-racingcar-precourse + +# ๐ŸŽ๏ธ ์ž๋™์ฐจ ๊ฒฝ์ฃผ + +## ๐Ÿ“‹ ๊ตฌํ˜„ ๊ธฐ๋Šฅ ๋ชฉ๋ก + +--- + +### 1๏ธโƒฃ ์ž…์ถœ๋ ฅ ๊ธฐ๋ณธ ํ”„๋กฌํ”„ํŠธ + +- [x] `Console.print("๊ฒฝ์ฃผํ•  ์ž๋™์ฐจ ์ด๋ฆ„์„ ์ž…๋ ฅํ•˜์„ธ์š”.(์ด๋ฆ„์€ ์‰ผํ‘œ(,) ๊ธฐ์ค€์œผ๋กœ ๊ตฌ๋ถ„)")` +- [x] `Console.readLineAsync("")`๋กœ ์ž๋™์ฐจ ์ด๋ฆ„ ์ž…๋ ฅ +- [x] `Console.print("์‹œ๋„ํ•  ํšŸ์ˆ˜๋Š” ๋ช‡ ํšŒ์ธ๊ฐ€์š”?")` +- [x] `Console.readLineAsync("")`๋กœ ์‹œ๋„ ํšŸ์ˆ˜ ์ž…๋ ฅ + +--- + +### 2๏ธโƒฃ ์ž…๋ ฅ ํŒŒ์‹ฑ & ๊ฒ€์ฆ โ€” ์ž๋™์ฐจ ์ด๋ฆ„ + +- [x] ์‰ผํ‘œ ๊ธฐ์ค€ ๋ถ„๋ฆฌ, ๊ฐ ์ด๋ฆ„ `trim()` +- [x] ๋นˆ ์ด๋ฆ„ ๊ธˆ์ง€, ์ „์ฒด ๋ชฉ๋ก ๋นˆ ๋ฐฐ์—ด ๊ธˆ์ง€ +- [x] ๊ฐ ์ด๋ฆ„ ๊ธธ์ด 1~5์ž ์ œํ•œ (์ดˆ๊ณผ ์‹œ ์—๋Ÿฌ) +- [x] ์˜ค๋ฅ˜ ์‹œ `[ERROR]`๋กœ ์‹œ์ž‘ํ•˜๋Š” ๋ฉ”์‹œ์ง€์™€ ํ•จ๊ป˜ `Error` throw + +--- + +### 3๏ธโƒฃ ์ž…๋ ฅ ํŒŒ์‹ฑ & ๊ฒ€์ฆ โ€” ์‹œ๋„ ํšŸ์ˆ˜ + +- [x] ์ •์ˆ˜ ๋ฌธ์ž์—ด์ธ์ง€ ํ™•์ธ +- [x] ์–‘์˜ ์ •์ˆ˜(>0)๋งŒ ํ—ˆ์šฉ +- [x] ์˜ค๋ฅ˜ ์‹œ `[ERROR]` ๋ฉ”์‹œ์ง€์™€ ํ•จ๊ป˜ `Error` throw + +--- + +### 4๏ธโƒฃ ์ง„ํ–‰ ๊ทœ์น™ ๊ตฌํ˜„ + +- [x] ๋ผ์šด๋“œ๋งˆ๋‹ค ๊ฐ ์ž๋™์ฐจ์— ๋Œ€ํ•ด `Random.pickNumberInRange(0, 9)` +- [x] 4 ์ด์ƒ์ด๋ฉด ์ „์ง„, ๋ฏธ๋งŒ์ด๋ฉด ์ •์ง€ +- [x] ๊ฐ ๋ผ์šด๋“œ ์ข…๋ฃŒ ํ›„ `์ด๋ฆ„ : -...` ํ˜•์‹์œผ๋กœ ์ƒํƒœ ์ถœ๋ ฅ(์ง„ํ–‰๋„๋งŒํผ `-`) + +--- + +### 5๏ธโƒฃ ์‹คํ–‰ ๊ฒฐ๊ณผ ์ถœ๋ ฅ ํ˜•์‹ + +- [x] ์ž…๋ ฅ ํ›„ `\n์‹คํ–‰ ๊ฒฐ๊ณผ` ์ถœ๋ ฅ +- [x] ๋ผ์šด๋“œ ์‚ฌ์ด ๋นˆ ์ค„ ์ถœ๋ ฅ(์˜ˆ์‹œ์™€ ๋™์ผ) +- [x] ๋ชจ๋“  ๋ผ์šด๋“œ ์ข…๋ฃŒ ํ›„ ์šฐ์Šน์ž(๋ณต์ˆ˜ ๊ฐ€๋Šฅ) ์ถœ๋ ฅ + +--- + +### 6๏ธโƒฃ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ์™€ ์ข…๋ฃŒ ํ๋ฆ„ + +- [x] `run()`์—์„œ ๋ชจ๋“  ์˜ˆ์™ธ `try/catch` +- [x] `[ERROR]`๋กœ ์‹œ์ž‘ํ•˜๋Š” ๋ฉ”์‹œ์ง€๋ฅผ Console.print๋กœ ์ถœ๋ ฅ +- [x] rethrowํ•˜์—ฌ ํ…Œ์ŠคํŠธ์˜ `rejects.toThrow`๋ฅผ ๋งŒ์กฑ +- [x] `process.exit()` ๊ธˆ์ง€ + +### 7๏ธโƒฃ ๊ตฌ์กฐํ™”/๋ชจ๋“ˆํ™” & ์Šคํƒ€์ผ + +- [x] I/O(`App.js`) โ†” ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง(`RacingGame.js`) ๋ถ„๋ฆฌ +- [x] ๋“ค์—ฌ์“ฐ๊ธฐ(depth) 2 ์ด๋‚ด, 3ํ•ญ ์—ฐ์‚ฐ์ž ์‚ฌ์šฉ ๊ธˆ์ง€ +- [x] ํ•จ์ˆ˜๋Š” ํ•œ ๊ฐ€์ง€ ์ผ๋งŒ ํ•˜๋„๋ก ๋ถ„๋ฆฌ diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 0260e7e8..4552fcbf 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -57,4 +57,59 @@ describe("์ž๋™์ฐจ ๊ฒฝ์ฃผ", () => { // then await expect(app.run()).rejects.toThrow("[ERROR]"); }); + + // ํŒŒ๋ผ๋ฏธํ„ฐํ™” ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + describe.each([ + ["", "[ERROR] ์ž๋™์ฐจ ์ด๋ฆ„์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค."], + ["pobi,javaji", "[ERROR] ์ž๋™์ฐจ ์ด๋ฆ„์€ 5์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."], + ["pobi, ", "[ERROR] ๋นˆ ์ด๋ฆ„์€ ํ—ˆ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."], + [" ,woni", "[ERROR] ๋นˆ ์ด๋ฆ„์€ ํ—ˆ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค."], + ])("์ด๋ฆ„ ๊ฒ€์ฆ - ์ž…๋ ฅ: %s", (nameLine, errorMsg) => { + test("run()๋Š” [ERROR]๋กœ reject ๋˜์–ด์•ผ ํ•œ๋‹ค", async () => { + mockQuestions([nameLine]); + const app = new App(); + await expect(app.run()).rejects.toThrow(errorMsg); + }); + }); + + describe.each([ + ["pobi,woni", "0", "[ERROR] ์‹œ๋„ ํšŸ์ˆ˜๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."], + ["pobi,woni", "-1", "[ERROR] ์‹œ๋„ ํšŸ์ˆ˜๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."], + ["pobi,woni", "abc", "[ERROR] ์‹œ๋„ ํšŸ์ˆ˜๋Š” ์ˆซ์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."], + ["pobi,woni", " ", "[ERROR] ์‹œ๋„ ํšŸ์ˆ˜๋Š” ์ˆซ์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค."], + ])("์‹œ๋„ ํšŸ์ˆ˜ ๊ฒ€์ฆ - ์ž…๋ ฅ: %s / %s", (nameLine, attemptLine, errorMsg) => { + test("run()๋Š” [ERROR]๋กœ reject ๋˜์–ด์•ผ ํ•œ๋‹ค", async () => { + mockQuestions([nameLine, attemptLine]); + const app = new App(); + await expect(app.run()).rejects.toThrow(errorMsg); + }); + }); + + describe.each([ + [ + [4, 3], + ["pobi : -", "woni : ", "์ตœ์ข… ์šฐ์Šน์ž : pobi"], + ], + [ + [3, 4], + ["pobi : ", "woni : -", "์ตœ์ข… ์šฐ์Šน์ž : woni"], + ], + [ + [4, 4], + ["pobi : -", "woni : -", "์ตœ์ข… ์šฐ์Šน์ž : pobi, woni"], + ], + ])("ํ•œ ๋ผ์šด๋“œ ์‹œ๋ฎฌ๋ ˆ์ด์…˜ - ๋žœ๋ค: %j", (randoms, expectedLogs) => { + test("๋ณด๋“œ/์šฐ์Šน์ž ์ถœ๋ ฅ์ด ํ˜•์‹์— ๋งž์•„์•ผ ํ•ฉ๋‹ˆ๋‹ค.", async () => { + const logSpy = getLogSpy(); + mockQuestions(["pobi,woni", "1"]); + mockRandoms(randoms); + + const app = new App(); + await app.run(); + + expectedLogs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + }); }); diff --git a/src/App.js b/src/App.js index 091aa0a5..9c341627 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,46 @@ +import { Console } from "@woowacourse/mission-utils"; +import { RacingGame } from "./RacingGame.js"; +import { ERROR, PROMPT } from "./constants/messages.js"; + class App { - async run() {} + async run() { + Console.print(PROMPT.ASK_NAMES); + try { + const nameLine = await Console.readLineAsync(""); + Console.print(PROMPT.ASK_ATTEMPTS); + const attemptLine = await Console.readLineAsync(""); + + const game = new RacingGame(nameLine, attemptLine); + const result = game.playAllRounds(); + + this.#printExecutionHeader(); + this.#printBoards(result.boards); + this.#printWinners(result.winners); + } catch (error) { + const message = error?.message ?? ERROR.UNKNOWN; + Console.print(message); + throw error; + } + } + + #printExecutionHeader() { + Console.print(""); + Console.print(PROMPT.EXEC_HEADER); + } + + #printBoards(boards) { + for (const board of boards) { + for (const line of board) { + Console.print(line); + } + Console.print(""); + } + } + + #printWinners(winners) { + const line = `์ตœ์ข… ์šฐ์Šน์ž : ${winners.join(", ")}`; + Console.print(line); + } } export default App; diff --git a/src/RacingGame.js b/src/RacingGame.js new file mode 100644 index 00000000..d6ccf70d --- /dev/null +++ b/src/RacingGame.js @@ -0,0 +1,95 @@ +import { Random } from "@woowacourse/mission-utils"; +import { ERROR } from "./constants/messages"; + +const MOVE_THRESHOLD = 4; +const RANDOM_MIN = 0; +const RANDOM_MAX = 9; + +export class RacingGame { + #cars; + #attempts; + + constructor(nameLine, attemptLine) { + const names = this.#parseNames(nameLine); + const attempts = this.#parseAttempts(attemptLine); + this.#cars = names.map((name) => ({ name, position: 0 })); + this.#attempts = attempts; + } + + playAllRounds() { + const boards = []; + for (let round = 0; round < this.#attempts; round += 1) { + this.#playOneRound(); + boards.push(this.#formatBoard()); + } + const winners = this.#determineWinners(); + return { boards, winners }; + } + + #playOneRound() { + for (const car of this.#cars) { + const randomValue = Random.pickNumberInRange(RANDOM_MIN, RANDOM_MAX); + if (randomValue >= MOVE_THRESHOLD) { + car.position += 1; + } + } + } + + #formatBoard() { + const lines = []; + for (const car of this.#cars) { + const hyphens = "-".repeat(car.position); + lines.push(`${car.name} : ${hyphens}`); + } + return lines; + } + + #determineWinners() { + const max = this.#cars.reduce((maxDistance, car) => { + if (car.position > maxDistance) return car.position; + return maxDistance; + }, 0); + const names = []; + for (const car of this.#cars) { + if (car.position === max) { + names.push(car.name); + } + } + return names; + } + + #parseNames(nameLine) { + const raw = (nameLine ?? "").trim(); + if (raw === "") { + throw new Error(ERROR.EMPTY_NAMES); + } + + const nameTokens = raw.split(",").map((s) => s.trim()); + + for (const nameToken of nameTokens) { + if (nameToken === "") { + throw new Error(ERROR.EMPTY_NAME_TOKEN); + } + if (nameToken.length > 5) { + throw new Error(ERROR.NAME_TOO_LONG); + } + } + + return nameTokens; + } + + #parseAttempts(attemptLine) { + const raw = (attemptLine ?? "").trim(); + + if (!/^-?\d+$/.test(raw)) { + throw new Error(ERROR.ATTEMPTS_NAN); + } + + const randomValue = Number(raw); + + if (!Number.isSafeInteger(randomValue) || randomValue <= 0) { + throw new Error(ERROR.ATTEMPTS_POSITIVE); + } + return randomValue; + } +} diff --git a/src/constants/messages.js b/src/constants/messages.js new file mode 100644 index 00000000..d0844157 --- /dev/null +++ b/src/constants/messages.js @@ -0,0 +1,13 @@ +export const PROMPT = { + ASK_NAMES: "๊ฒฝ์ฃผํ•  ์ž๋™์ฐจ ์ด๋ฆ„์„ ์ž…๋ ฅํ•˜์„ธ์š”.(์ด๋ฆ„์€ ์‰ผํ‘œ(,) ๊ธฐ์ค€์œผ๋กœ ๊ตฌ๋ถ„)", + ASK_ATTEMPTS: "์‹œ๋„ํ•  ํšŸ์ˆ˜๋Š” ๋ช‡ ํšŒ์ธ๊ฐ€์š”?", + EXEC_HEADER: "์‹คํ–‰ ๊ฒฐ๊ณผ", +}; +export const ERROR = { + EMPTY_NAMES: "[ERROR] ์ž๋™์ฐจ ์ด๋ฆ„์ด ๋น„์–ด ์žˆ์Šต๋‹ˆ๋‹ค.", + EMPTY_NAME_TOKEN: "[ERROR] ๋นˆ ์ด๋ฆ„์€ ํ—ˆ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.", + NAME_TOO_LONG: "[ERROR] ์ž๋™์ฐจ ์ด๋ฆ„์€ 5์ž ์ดํ•˜์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + ATTEMPTS_NAN: "[ERROR] ์‹œ๋„ ํšŸ์ˆ˜๋Š” ์ˆซ์ž์—ฌ์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + ATTEMPTS_POSITIVE: "[ERROR] ์‹œ๋„ ํšŸ์ˆ˜๋Š” 1 ์ด์ƒ์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.", + UNKNOWN: "[ERROR] ์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.", +}; diff --git a/types/woowacourse__mission-utils.d.ts b/types/woowacourse__mission-utils.d.ts new file mode 100644 index 00000000..00574645 --- /dev/null +++ b/types/woowacourse__mission-utils.d.ts @@ -0,0 +1,6 @@ +declare module "@woowacourse/mission-utils" { + export const Console: { + readLineAsync(prompt?: string): Promise; + print(message: string): void; + }; +}