diff --git a/README.md b/README.md index e078fd41..05111502 100644 --- a/README.md +++ b/README.md @@ -1 +1,78 @@ -# javascript-racingcar-precourse +# 🏎️ μžλ™μ°¨ κ²½μ£Ό κΈ°λŠ₯ λͺ…μ„Έ + +## 1. Domain + +### 1) `Car` + +- μžλ™μ°¨μ˜ 이름과 이동 거리λ₯Ό λ³΄μœ ν•©λ‹ˆλ‹€. +- μ „μ§„ λͺ…령을 λ°›μ•„ 이동 거리λ₯Ό 1 μ¦κ°€μ‹œν‚΅λ‹ˆλ‹€. +- μžλ™μ°¨ 이름과 ν˜„μž¬ 이동 거리λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€. + +## 2. Model + +### 1) `RaceSimulator` + +- μžλ™μ°¨ 이름과 μ‹œλ„ 횟수λ₯Ό λ°›μ•„ κ²½μ£Ό 전체λ₯Ό κ΄€λ¦¬ν•©λ‹ˆλ‹€. +- `createCars` ν•¨μˆ˜λ₯Ό μ‚¬μš©ν•΄ μžλ™μ°¨ λͺ©λ‘μ„ μƒμ„±ν•©λ‹ˆλ‹€. +- 남은 μ‹œλ„ 횟수λ₯Ό μΆ”μ ν•˜λ©°, 각 ν„΄λ§ˆλ‹€ `runOneStep` ν•¨μˆ˜λ₯Ό ν˜ΈμΆœν•΄ 이동을 μ²˜λ¦¬ν•©λ‹ˆλ‹€. +- 각 λ‹¨κ³„μ˜ μžλ™μ°¨ μœ„μΉ˜ 정보λ₯Ό λ°˜ν™˜ν•©λ‹ˆλ‹€. +- μ΅œμ’… 우승자λ₯Ό 계산해 λ°˜ν™˜ν•©λ‹ˆλ‹€. + +### 2) `raceUtils` + +- κ²½μ£Ό κ΄€λ ¨ λ‘œμ§μ„ μœ ν‹Έ ν•¨μˆ˜ ν˜•μ‹μœΌλ‘œ μ œκ³΅ν•©λ‹ˆλ‹€. + +#### `createCars(carNames)` + +- μ‰Όν‘œλ‘œ κ΅¬λΆ„λœ μžλ™μ°¨ 이름 λ¬Έμžμ—΄μ„ μž…λ ₯λ°›μ•„, 각 μ΄λ¦„μ˜ 곡백을 μ œκ±°ν•©λ‹ˆλ‹€. +- 각 이름에 λŒ€ν•΄ `Car` μΈμŠ€ν„΄μŠ€λ₯Ό 생성해 λ°°μ—΄λ‘œ λ°˜ν™˜ν•©λ‹ˆλ‹€. + +#### `runOneStep(cars)` + +- μžλ™μ°¨ 배열을 λ°›μ•„ ν•œ 번의 이동 μ‹œλ„λ₯Ό μ²˜λ¦¬ν•©λ‹ˆλ‹€. +- 각 μžλ™μ°¨λ§ˆλ‹€ 0~9 λ²”μœ„μ˜ λ‚œμˆ˜λ₯Ό μƒμ„±ν•˜κ³ , + λ‚œμˆ˜κ°€ 4 이상일 경우 μ „μ§„μ‹œν‚΅λ‹ˆλ‹€. + +## 3. View + +### 1) `InputView` + +- μ‚¬μš©μžμ—κ²Œ μž…λ ₯ μ•ˆλ‚΄ λ©”μ‹œμ§€λ₯Ό ν‘œμ‹œν•©λ‹ˆλ‹€. +- μžλ™μ°¨ 이름듀을 μž…λ ₯λ°›μŠ΅λ‹ˆλ‹€. +- μ‹œλ„ 횟수λ₯Ό μž…λ ₯λ°›μŠ΅λ‹ˆλ‹€. + +### 2) `OutputView` + +- μ‹€ν–‰ κ²°κ³Ό 헀더λ₯Ό 좜λ ₯ν•©λ‹ˆλ‹€. +- λ‹¨κ³„λ§ˆλ‹€ μžλ™μ°¨λ³„ ν˜„μž¬ μœ„μΉ˜λ₯Ό 좜λ ₯ν•©λ‹ˆλ‹€. +- μ΅œμ’… 우승자λ₯Ό 좜λ ₯ν•©λ‹ˆλ‹€. + +## 4. Controller + +### 1) `RaceController` + +- `InputView`λ₯Ό 톡해 μžλ™μ°¨ 이름과 μ‹œλ„ 횟수λ₯Ό μž…λ ₯λ°›μŠ΅λ‹ˆλ‹€. +- `RaceSimulator`λ₯Ό μƒμ„±ν•˜μ—¬ κ²½μ£Όλ₯Ό μ΄ˆκΈ°ν™”ν•©λ‹ˆλ‹€. +- `OutputView`둜 κ²°κ³Ό 헀더λ₯Ό 좜λ ₯ν•©λ‹ˆλ‹€. +- 남은 μ‹œλ„κ°€ μžˆλŠ” λ™μ•ˆ λ°˜λ³΅ν•˜μ—¬ 각 단계λ₯Ό μ‹€ν–‰ν•©λ‹ˆλ‹€. +- 각 λ‹¨κ³„μ˜ κ²°κ³Όλ₯Ό `OutputView`둜 좜λ ₯ν•©λ‹ˆλ‹€. +- κ²½μ£Ό μ’…λ£Œ ν›„ 우승자λ₯Ό `OutputView`둜 좜λ ₯ν•©λ‹ˆλ‹€. + +## 5. μ˜ˆμ™Έ 처리 + +### 1) μžλ™μ°¨ 이름 검증 + +- μžλ™μ°¨ 이름 λͺ©λ‘μ΄ μ‰Όν‘œλ‘œ κ΅¬λΆ„λœ μ˜¬λ°”λ₯Έ ν˜•μ‹μ΄ μ•„λ‹Œ 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. +- μžλ™μ°¨ μ΄λ¦„μ˜ 길이λ₯Ό κ²€μ¦ν•©λ‹ˆλ‹€. +- 이름이 5자λ₯Ό μ΄ˆκ³Όν•˜λŠ” 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. +- 이름이 λΉ„μ–΄μžˆλŠ” 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. +- 이름이 μ€‘λ³΅λœ 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. + +### 2) μ‹œλ„ 횟수 검증 + +- μ‹œλ„ νšŸμˆ˜κ°€ μˆ«μžκ°€ μ•„λ‹Œ 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. +- μ‹œλ„ νšŸμˆ˜κ°€ 0 μ΄ν•˜μΈ 경우 μ˜ˆμ™Έλ₯Ό λ°œμƒμ‹œν‚΅λ‹ˆλ‹€. + +### 3) μ—λŸ¬ λ©”μ‹œμ§€ + +- λͺ¨λ“  μ˜ˆμ™ΈλŠ” `[ERROR]`둜 μ‹œμž‘ν•˜λŠ” λ©”μ‹œμ§€λ₯Ό μ œκ³΅ν•©λ‹ˆλ‹€. diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 0260e7e8..b320a358 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -1,5 +1,7 @@ -import App from "../src/App.js"; -import { MissionUtils } from "@woowacourse/mission-utils"; +import { MissionUtils } from '@woowacourse/mission-utils'; +import App from '../src/App.js'; +import TEST_CONFIG from '../src/constants/testConfig.js'; +import ERROR_MESSAGES from '../src/constants/errorMessages.js'; const mockQuestions = (inputs) => { MissionUtils.Console.readLineAsync = jest.fn(); @@ -19,42 +21,114 @@ const mockRandoms = (numbers) => { }; const getLogSpy = () => { - const logSpy = jest.spyOn(MissionUtils.Console, "print"); + const logSpy = jest.spyOn(MissionUtils.Console, 'print'); logSpy.mockClear(); return logSpy; }; -describe("μžλ™μ°¨ κ²½μ£Ό", () => { - test("κΈ°λŠ₯ ν…ŒμŠ€νŠΈ", async () => { - // given - const MOVING_FORWARD = 4; - const STOP = 3; - const inputs = ["pobi,woni", "1"]; - const logs = ["pobi : -", "woni : ", "μ΅œμ’… 우승자 : pobi"]; - const logSpy = getLogSpy(); +const runApp = async (inputs, randoms = []) => { + mockQuestions(inputs); + if (randoms.length > 0) { + mockRandoms(randoms); + } + const app = new App(); + await app.run(); +}; + +const expectLogsContain = (logSpy, logs) => { + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); +}; + +describe('μžλ™μ°¨ κ²½μ£Ό 톡합 ν…ŒμŠ€νŠΈ', () => { + describe('정상 λ™μž‘ μ‹œλ‚˜λ¦¬μ˜€', () => { + test('μš°μŠΉμžκ°€ ν•œ λͺ…인 경우 단독 우승자λ₯Ό 좜λ ₯ν•œλ‹€', async () => { + // given + const inputs = ['pobi,woni', '1']; + const logs = ['pobi : -', 'woni : ', 'μ΅œμ’… 우승자 : pobi']; + const logSpy = getLogSpy(); + + // when + await runApp(inputs, [TEST_CONFIG.MOVING_FORWARD, TEST_CONFIG.STOP]); + + // then + expectLogsContain(logSpy, logs); + }); + + test('μš°μŠΉμžκ°€ μ—¬λŸ¬ λͺ…인 경우 λͺ¨λ“  우승자λ₯Ό 좜λ ₯ν•œλ‹€', async () => { + // given + const inputs = ['pobi,woni,jun', '2']; + const logs = ['pobi : --', 'woni : --', 'jun : -', 'μ΅œμ’… 우승자 : pobi, woni']; + const logSpy = getLogSpy(); + + // when + await runApp(inputs, [ + TEST_CONFIG.MOVING_FORWARD, + TEST_CONFIG.MOVING_FORWARD, + TEST_CONFIG.MOVING_FORWARD, + TEST_CONFIG.MOVING_FORWARD, + TEST_CONFIG.MOVING_FORWARD, + TEST_CONFIG.STOP, + ]); + + // then + expectLogsContain(logSpy, logs); + }); - mockQuestions(inputs); - mockRandoms([MOVING_FORWARD, STOP]); + test('μžλ™μ°¨ 이름 μ•žλ’€μ˜ 곡백을 μ œκ±°ν•˜κ³  κ²½μ£Όλ₯Ό μ§„ν–‰ν•œλ‹€', async () => { + // given + const inputs = [' pobi , woni ', '1']; + const logs = ['pobi : -', 'woni : -']; + const logSpy = getLogSpy(); - // when - const app = new App(); - await app.run(); + // when + await runApp(inputs, [TEST_CONFIG.MOVING_FORWARD, TEST_CONFIG.MOVING_FORWARD]); - // then - logs.forEach((log) => { - expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + // then + expectLogsContain(logSpy, logs); }); }); - test("μ˜ˆμ™Έ ν…ŒμŠ€νŠΈ", async () => { - // given - const inputs = ["pobi,javaji"]; - mockQuestions(inputs); + describe('μœ νš¨μ„± 검사 μ˜ˆμ™Έ μΌ€μ΄μŠ€', () => { + test('μžλ™μ°¨ 이름이 5자λ₯Ό μ΄ˆκ³Όν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', async () => { + // given + const inputs = ['pobi,javaji', '1']; - // when - const app = new App(); + // when & then + await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.NAME_TOO_LONG); + }); + + test('μ€‘λ³΅λœ μžλ™μ°¨ 이름이 있으면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', async () => { + // given + const inputs = ['pobi,woni,pobi', '1']; + + // when & then + await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.NAME_DUPLICATED); + }); + + test('μžλ™μ°¨ 이름에 빈 값이 있으면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', async () => { + // given + const inputs = ['pobi,,woni', '1']; + + // when & then + await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.NAME_LIST_FORMAT_INVALID); + }); + + test('μ‹œλ„ νšŸμˆ˜κ°€ μˆ«μžκ°€ μ•„λ‹Œ λ¬Έμžμ—΄μ΄λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', async () => { + // given + const inputs = ['pobi,woni', 'abc']; + + // when & then + await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.ATTEMPT_COUNT_NON_NUMERIC); + }); + + test('μ‹œλ„ νšŸμˆ˜κ°€ 0μ΄ν•˜λ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', async () => { + // given + const inputs = ['pobi,woni', '0']; - // then - await expect(app.run()).rejects.toThrow("[ERROR]"); + // when & then + await expect(runApp(inputs)).rejects.toThrow(ERROR_MESSAGES.ATTEMPT_COUNT_NOT_POSITIVE); + }); }); }); diff --git a/src/App.js b/src/App.js index 091aa0a5..69b7624e 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,10 @@ +import RaceController from './controllers/RaceController.js'; + class App { - async run() {} + async run() { + const controller = new RaceController(); + await controller.start(); + } } export default App; diff --git a/src/constants/errorMessages.js b/src/constants/errorMessages.js new file mode 100644 index 00000000..e037fbd4 --- /dev/null +++ b/src/constants/errorMessages.js @@ -0,0 +1,14 @@ +const ERROR_PREFIX = '[ERROR]'; + +const ERROR_MESSAGES = Object.freeze({ + NAME_TOO_LONG: `${ERROR_PREFIX} μžλ™μ°¨ 이름은 5자 μ΄ν•˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.`, + NAME_EMPTY: `${ERROR_PREFIX} μžλ™μ°¨ 이름을 μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.`, + NAME_LIST_FORMAT_INVALID: `${ERROR_PREFIX} μžλ™μ°¨ 이름은 μ‰Όν‘œλ‘œ κ΅¬λΆ„λœ μ˜¬λ°”λ₯Έ ν˜•μ‹μœΌλ‘œ μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.`, + NAME_DUPLICATED: `${ERROR_PREFIX} μžλ™μ°¨ 이름은 쀑볡될 수 μ—†μŠ΅λ‹ˆλ‹€.`, + ATTEMPT_COUNT_EMPTY: `${ERROR_PREFIX} μ‹œλ„ 횟수λ₯Ό μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.`, + ATTEMPT_COUNT_NON_NUMERIC: `${ERROR_PREFIX} μ‹œλ„ νšŸμˆ˜λŠ” 숫자만 μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.`, + ATTEMPT_COUNT_NOT_POSITIVE: `${ERROR_PREFIX} μ‹œλ„ νšŸμˆ˜λŠ” 0보닀 컀야 ν•©λ‹ˆλ‹€.`, + ATTEMPT_COUNT_NOT_INTEGER: `${ERROR_PREFIX} μ‹œλ„ νšŸμˆ˜λŠ” μ •μˆ˜λ‘œ μž…λ ₯ν•΄μ•Ό ν•©λ‹ˆλ‹€.`, +}); + +export default ERROR_MESSAGES; diff --git a/src/constants/promptMessages.js b/src/constants/promptMessages.js new file mode 100644 index 00000000..dbc9a759 --- /dev/null +++ b/src/constants/promptMessages.js @@ -0,0 +1,8 @@ +const PROMPT_MESSAGES = Object.freeze({ + CAR_NAMES_INPUT: 'κ²½μ£Όν•  μžλ™μ°¨ 이름을 μž…λ ₯ν•˜μ„Έμš”.(이름은 μ‰Όν‘œ(,) κΈ°μ€€μœΌλ‘œ ꡬ뢄)\n', + ATTEMPT_COUNT_INPUT: 'μ‹œλ„ν•  νšŸμˆ˜λŠ” λͺ‡ νšŒμΈκ°€μš”?\n', + RESULT_HEADER: '\nμ‹€ν–‰ κ²°κ³Ό', + FINAL_WINNERS_PREFIX: 'μ΅œμ’… 우승자', +}); + +export default PROMPT_MESSAGES; diff --git a/src/constants/raceConfig.js b/src/constants/raceConfig.js new file mode 100644 index 00000000..08de576b --- /dev/null +++ b/src/constants/raceConfig.js @@ -0,0 +1,8 @@ +const RACE_CONFIG = Object.freeze({ + MOVE_THRESHOLD: 4, + MIN_RANDOM_VALUE: 0, + MAX_RANDOM_VALUE: 9, + MAX_CAR_NAME_LENGTH: 5, +}); + +export default RACE_CONFIG; diff --git a/src/constants/testConfig.js b/src/constants/testConfig.js new file mode 100644 index 00000000..407abe6d --- /dev/null +++ b/src/constants/testConfig.js @@ -0,0 +1,6 @@ +const TEST_CONFIG = Object.freeze({ + MOVING_FORWARD: 4, + STOP: 3, +}); + +export default TEST_CONFIG; diff --git a/src/controllers/RaceController.js b/src/controllers/RaceController.js new file mode 100644 index 00000000..d7a4e9d6 --- /dev/null +++ b/src/controllers/RaceController.js @@ -0,0 +1,51 @@ +import RaceSimulator from '../models/RaceSimulator.js'; +import InputView from '../views/InputView.js'; +import OutputView from '../views/OutputView.js'; +import { validateNameListFormat, validateNumericValue } from '../utils/validators.js'; +import PROMPT_MESSAGES from '../constants/promptMessages.js'; + +class RaceController { + #inputView; + #outputView; + + constructor() { + this.#inputView = new InputView(); + this.#outputView = new OutputView(); + } + + async start() { + const carNames = await this.#readCarNames(); + validateNameListFormat(carNames); + const attemptCount = await this.#readAttemptCount(); + validateNumericValue(attemptCount); + this.#runRace(carNames, Number(attemptCount)); + } + + async #readCarNames() { + return this.#inputView.readString(PROMPT_MESSAGES.CAR_NAMES_INPUT); + } + + async #readAttemptCount() { + return this.#inputView.readString(PROMPT_MESSAGES.ATTEMPT_COUNT_INPUT); + } + + #printRaceProgress(simulator) { + this.#outputView.printResultHeader(); + while (simulator.hasRemainingAttempts()) { + const positions = simulator.executeStep(); + this.#outputView.printStepResult(positions); + } + } + + #printFinalResults(simulator) { + this.#outputView.printWinners(simulator.getRaceWinners()); + } + + #runRace(carNames, attemptCount) { + const simulator = new RaceSimulator(carNames, attemptCount); + this.#printRaceProgress(simulator); + this.#printFinalResults(simulator); + } +} + +export default RaceController; diff --git a/src/domains/Car.js b/src/domains/Car.js new file mode 100644 index 00000000..eefc66e4 --- /dev/null +++ b/src/domains/Car.js @@ -0,0 +1,26 @@ +import { validateNameLength } from '../utils/validators.js'; + +class Car { + #name; + #position; + + constructor(name) { + validateNameLength(name); + this.#name = name; + this.#position = 0; + } + + move() { + this.#position += 1; + } + + getName() { + return this.#name; + } + + getPosition() { + return this.#position; + } +} + +export default Car; diff --git a/src/domains/Car.test.js b/src/domains/Car.test.js new file mode 100644 index 00000000..99aaec46 --- /dev/null +++ b/src/domains/Car.test.js @@ -0,0 +1,57 @@ +import Car from './Car.js'; +import ERROR_MESSAGES from '../constants/errorMessages.js'; + +describe('Car', () => { + describe('μƒμ„±μž', () => { + test('μœ νš¨ν•œ μž…λ ₯이 μ£Όμ–΄μ§€λ©΄ Car μΈμŠ€ν„΄μŠ€λ₯Ό μƒμ„±ν•œλ‹€', () => { + expect(() => new Car('pobi')).not.toThrow(); + }); + + test('이름이 6자 이상이면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', () => { + expect(() => new Car('longname')).toThrow(ERROR_MESSAGES.NAME_TOO_LONG); + }); + + test('이름이 λΉ„μ–΄ 있으면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', () => { + expect(() => new Car('')).toThrow(ERROR_MESSAGES.NAME_EMPTY); + }); + }); + + describe('move λ©”μ„œλ“œ', () => { + test('move 호좜 μ‹œ μœ„μΉ˜κ°€ 1 μ¦κ°€ν•œλ‹€', () => { + // Arrange + const car = new Car('pobi'); + + // Act + car.move(); + + // Assert + expect(car.getPosition()).toBe(1); + }); + }); + + describe('getName λ©”μ„œλ“œ', () => { + test('μžλ™μ°¨μ˜ 이름을 λ°˜ν™˜ν•œλ‹€', () => { + // Arrange + const car = new Car('pobi'); + + // Act + const name = car.getName(); + + // Assert + expect(name).toBe('pobi'); + }); + }); + + describe('getPosition λ©”μ„œλ“œ', () => { + test('μžλ™μ°¨μ˜ ν˜„μž¬ μœ„μΉ˜λ₯Ό λ°˜ν™˜ν•œλ‹€', () => { + // Arrange + const car = new Car('pobi'); + + // Act + const position = car.getPosition(); + + // Assert + expect(position).toBe(0); + }); + }); +}); diff --git a/src/index.js b/src/index.js index 02a1d389..9daefc93 100644 --- a/src/index.js +++ b/src/index.js @@ -1,4 +1,4 @@ -import App from "./App.js"; +import App from './App.js'; const app = new App(); await app.run(); diff --git a/src/models/RaceSimulator.js b/src/models/RaceSimulator.js new file mode 100644 index 00000000..631e94db --- /dev/null +++ b/src/models/RaceSimulator.js @@ -0,0 +1,36 @@ +import { createCars, runOneStep } from './utils/raceUtils.js'; +import { validatePositiveInteger } from '../utils/validators.js'; + +class RaceSimulator { + #cars; + #remainingAttempts; + + constructor(carNames, attemptCount) { + this.#cars = createCars(carNames); + validatePositiveInteger(attemptCount); + this.#remainingAttempts = attemptCount; + } + + #getCurrentPositions() { + return this.#cars.map((car) => [car.getName(), car.getPosition()]); + } + + executeStep() { + this.#remainingAttempts -= 1; + runOneStep(this.#cars); + return this.#getCurrentPositions(); + } + + hasRemainingAttempts() { + return this.#remainingAttempts > 0; + } + + getRaceWinners() { + const maxDistance = Math.max(...this.#cars.map((car) => car.getPosition())); + return this.#cars + .filter((car) => car.getPosition() === maxDistance) + .map((car) => car.getName()); + } +} + +export default RaceSimulator; diff --git a/src/models/RaceSimulator.test.js b/src/models/RaceSimulator.test.js new file mode 100644 index 00000000..ea422db7 --- /dev/null +++ b/src/models/RaceSimulator.test.js @@ -0,0 +1,127 @@ +import { Random } from '@woowacourse/mission-utils'; +import RaceSimulator from './RaceSimulator.js'; +import TEST_CONFIG from '../constants/testConfig.js'; +import ERROR_MESSAGES from '../constants/errorMessages.js'; + +Random.pickNumberInRange = jest.fn(); + +describe('RaceSimulator', () => { + beforeEach(() => { + Random.pickNumberInRange.mockClear(); + }); + + describe('μƒμ„±μž', () => { + test('μœ νš¨ν•œ μž…λ ₯이 μ£Όμ–΄μ§€λ©΄ RaceSimulator μΈμŠ€ν„΄μŠ€λ₯Ό μƒμ„±ν•œλ‹€', () => { + expect(() => new RaceSimulator('pobi,woni', 5)).not.toThrow(); + }); + + test('μ‹œλ„ νšŸμˆ˜κ°€ μ–‘μˆ˜κ°€ μ•„λ‹ˆλ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', () => { + expect(() => new RaceSimulator('pobi,woni', 0)).toThrow( + ERROR_MESSAGES.ATTEMPT_COUNT_NOT_POSITIVE + ); + expect(() => new RaceSimulator('pobi,woni', -1)).toThrow( + ERROR_MESSAGES.ATTEMPT_COUNT_NOT_POSITIVE + ); + }); + + test('μ‹œλ„ νšŸμˆ˜κ°€ μ •μˆ˜κ°€ μ•„λ‹ˆλ©΄ μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', () => { + expect(() => new RaceSimulator('pobi,woni', 1.1)).toThrow( + ERROR_MESSAGES.ATTEMPT_COUNT_NOT_INTEGER + ); + }); + }); + + describe('executeStep λ©”μ„œλ“œ', () => { + test('ν•œ μŠ€ν… μ‹€ν–‰ ν›„ 각 μžλ™μ°¨μ˜ 이름과 μœ„μΉ˜λ₯Ό λ°˜ν™˜ν•œλ‹€', () => { + // Arrange + Random.pickNumberInRange.mockReturnValue(TEST_CONFIG.MOVING_FORWARD); + const simulator = new RaceSimulator('pobi,woni', 5); + + // Act + const result = simulator.executeStep(); + + // Assert + expect(result).toEqual([ + ['pobi', 1], + ['woni', 1], + ]); + }); + + test('μŠ€ν… μ‹€ν–‰ μ‹œ 남은 μ‹œλ„ νšŸμˆ˜κ°€ κ°μ†Œν•œλ‹€', () => { + // Arrange + Random.pickNumberInRange.mockReturnValue(TEST_CONFIG.MOVING_FORWARD); + const simulator = new RaceSimulator('pobi,woni', 2); + + // Assert: 초기 μƒνƒœ + expect(simulator.hasRemainingAttempts()).toBe(true); + + // Act & Assert: 1회 μ‹€ν–‰ ν›„ + simulator.executeStep(); + expect(simulator.hasRemainingAttempts()).toBe(true); + + // Act & Assert: 2회 μ‹€ν–‰ ν›„ + simulator.executeStep(); + expect(simulator.hasRemainingAttempts()).toBe(false); + }); + }); + + describe('hasRemainingAttempts λ©”μ„œλ“œ', () => { + test('남은 μ‹œλ„ νšŸμˆ˜κ°€ 있으면 trueλ₯Ό λ°˜ν™˜ν•œλ‹€', () => { + // Arrange + const simulator = new RaceSimulator('pobi,woni', 5); + + // Act + const result = simulator.hasRemainingAttempts(); + + // Assert + expect(result).toBe(true); + }); + + test('남은 μ‹œλ„ νšŸμˆ˜κ°€ μ—†μœΌλ©΄ falseλ₯Ό λ°˜ν™˜ν•œλ‹€', () => { + // Arrange + Random.pickNumberInRange.mockReturnValue(TEST_CONFIG.MOVING_FORWARD); + const simulator = new RaceSimulator('pobi,woni', 1); + + // Act + simulator.executeStep(); + const result = simulator.hasRemainingAttempts(); + + // Assert + expect(result).toBe(false); + }); + }); + + describe('getRaceWinners λ©”μ„œλ“œ', () => { + test('κ°€μž₯ 멀리 κ°„ μžλ™μ°¨μ˜ 이름을 λ°˜ν™˜ν•œλ‹€', () => { + // Arrange + Random.pickNumberInRange + .mockReturnValueOnce(TEST_CONFIG.MOVING_FORWARD) + .mockReturnValueOnce(TEST_CONFIG.STOP) + .mockReturnValueOnce(TEST_CONFIG.MOVING_FORWARD) + .mockReturnValueOnce(TEST_CONFIG.MOVING_FORWARD); + const simulator = new RaceSimulator('pobi,woni', 2); + + // Act + simulator.executeStep(); + simulator.executeStep(); + const winners = simulator.getRaceWinners(); + + // Assert + expect(winners).toEqual(['pobi']); + }); + + test('동λ₯ μ΄λ©΄ λͺ¨λ“  우승자λ₯Ό λ°˜ν™˜ν•œλ‹€', () => { + // Arrange + Random.pickNumberInRange.mockReturnValue(TEST_CONFIG.MOVING_FORWARD); + const simulator = new RaceSimulator('pobi,woni', 2); + + // Act + simulator.executeStep(); + simulator.executeStep(); + const winners = simulator.getRaceWinners(); + + // Assert + expect(winners).toEqual(['pobi', 'woni']); + }); + }); +}); diff --git a/src/models/utils/raceUtils.js b/src/models/utils/raceUtils.js new file mode 100644 index 00000000..daba6766 --- /dev/null +++ b/src/models/utils/raceUtils.js @@ -0,0 +1,21 @@ +import { Random } from '@woowacourse/mission-utils'; +import Car from '../../domains/Car.js'; +import { validateNameDuplication } from '../../utils/validators.js'; +import RACE_CONFIG from '../../constants/raceConfig.js'; + +export const createCars = (carNames) => { + const carNameList = carNames.split(',').map((name) => name.trim()); + validateNameDuplication(carNameList); + return carNameList.map((name) => new Car(name)); +}; + +export const runOneStep = (cars) => { + cars.forEach((car) => { + if ( + Random.pickNumberInRange(RACE_CONFIG.MIN_RANDOM_VALUE, RACE_CONFIG.MAX_RANDOM_VALUE) >= + RACE_CONFIG.MOVE_THRESHOLD + ) { + car.move(); + } + }); +}; diff --git a/src/models/utils/raceUtils.test.js b/src/models/utils/raceUtils.test.js new file mode 100644 index 00000000..fb4f56b3 --- /dev/null +++ b/src/models/utils/raceUtils.test.js @@ -0,0 +1,83 @@ +import { Random } from '@woowacourse/mission-utils'; +import { createCars, runOneStep } from './raceUtils.js'; +import TEST_CONFIG from '../../constants/testConfig.js'; +import ERROR_MESSAGES from '../../constants/errorMessages.js'; + +Random.pickNumberInRange = jest.fn(); + +describe('raceUtils', () => { + describe('createCars', () => { + test('μ‰Όν‘œλ‘œ κ΅¬λΆ„λœ μ΄λ¦„μœΌλ‘œ μžλ™μ°¨ 배열을 μƒμ„±ν•œλ‹€', () => { + // Arrange + const names = 'pobi,woni,jun'; + + // Act + const cars = createCars(names); + + // Assert + expect(cars).toHaveLength(3); + expect(cars[0].getName()).toBe('pobi'); + expect(cars[1].getName()).toBe('woni'); + expect(cars[2].getName()).toBe('jun'); + }); + + test('이름 μ•žλ’€μ— 곡백이 μžˆλŠ” 경우 곡백을 μ œκ±°ν•œλ‹€', () => { + // Arrange + const names = ' pobi , woni , jun '; + + // Act + const cars = createCars(names); + + // Assert + expect(cars[0].getName()).toBe('pobi'); + expect(cars[1].getName()).toBe('woni'); + expect(cars[2].getName()).toBe('jun'); + }); + + test('μ€‘λ³΅λœ 이름이 있으면 μ˜ˆμ™Έκ°€ λ°œμƒν•œλ‹€', () => { + expect(() => createCars('pobi,woni,pobi')).toThrow(ERROR_MESSAGES.NAME_DUPLICATED); + expect(() => createCars('pobi, woni, pobi')).toThrow(ERROR_MESSAGES.NAME_DUPLICATED); + }); + }); + + describe('runOneStep', () => { + test('랜덀 값이 4 이상이면 μžλ™μ°¨κ°€ μ „μ§„ν•œλ‹€', () => { + // Arrange + Random.pickNumberInRange.mockReturnValue(TEST_CONFIG.MOVING_FORWARD); + const cars = createCars('pobi'); + + // Act + runOneStep(cars); + + // Assert + expect(cars[0].getPosition()).toBe(1); + }); + + test('랜덀 값이 4 미만이면 μžλ™μ°¨κ°€ μ „μ§„ν•˜μ§€ μ•ŠλŠ”λ‹€', () => { + // Arrange + Random.pickNumberInRange.mockReturnValue(TEST_CONFIG.STOP); + const cars = createCars('pobi'); + + // Act + runOneStep(cars); + + // Assert + expect(cars[0].getPosition()).toBe(0); + }); + + test('각 μžλ™μ°¨μ˜ μ „μ§„ μ—¬λΆ€λŠ” λ…λ¦½μ μœΌλ‘œ κ²°μ •λœλ‹€', () => { + // Arrange + Random.pickNumberInRange + .mockReturnValueOnce(TEST_CONFIG.MOVING_FORWARD) + .mockReturnValueOnce(TEST_CONFIG.STOP); + const cars = createCars('pobi,woni'); + + // Act + runOneStep(cars); + + // Assert + expect(cars[0].getPosition()).toBe(1); + expect(cars[1].getPosition()).toBe(0); + }); + }); +}); diff --git a/src/utils/validators.js b/src/utils/validators.js new file mode 100644 index 00000000..e9a8952d --- /dev/null +++ b/src/utils/validators.js @@ -0,0 +1,43 @@ +import ERROR_MESSAGES from '../constants/errorMessages.js'; +import RACE_CONFIG from '../constants/raceConfig.js'; + +export function validateNameLength(carName) { + if (carName.length > RACE_CONFIG.MAX_CAR_NAME_LENGTH) { + throw new Error(ERROR_MESSAGES.NAME_TOO_LONG); + } + if (carName.length === 0) { + throw new Error(ERROR_MESSAGES.NAME_EMPTY); + } +} + +export function validateNameListFormat(rawNames) { + const VALID_NAME_LIST_PATTERN = /^[^,]+(,[^,]+)*$/; + + if (!VALID_NAME_LIST_PATTERN.test(rawNames)) { + throw new Error(ERROR_MESSAGES.NAME_LIST_FORMAT_INVALID); + } +} + +export function validateNameDuplication(carNames) { + if (new Set(carNames).size !== carNames.length) { + throw new Error(ERROR_MESSAGES.NAME_DUPLICATED); + } +} + +export function validateNumericValue(attemptInput) { + if (attemptInput === '') { + throw new Error(ERROR_MESSAGES.ATTEMPT_COUNT_EMPTY); + } + if (isNaN(attemptInput)) { + throw new Error(ERROR_MESSAGES.ATTEMPT_COUNT_NON_NUMERIC); + } +} + +export function validatePositiveInteger(number) { + if (number <= 0) { + throw new Error(ERROR_MESSAGES.ATTEMPT_COUNT_NOT_POSITIVE); + } + if (!Number.isInteger(number)) { + throw new Error(ERROR_MESSAGES.ATTEMPT_COUNT_NOT_INTEGER); + } +} diff --git a/src/views/InputView.js b/src/views/InputView.js new file mode 100644 index 00000000..6ef314ec --- /dev/null +++ b/src/views/InputView.js @@ -0,0 +1,10 @@ +import { Console } from '@woowacourse/mission-utils'; + +class InputView { + async readString(promptMessage) { + const input = await Console.readLineAsync(promptMessage); + return input; + } +} + +export default InputView; diff --git a/src/views/OutputView.js b/src/views/OutputView.js new file mode 100644 index 00000000..b2ac70bf --- /dev/null +++ b/src/views/OutputView.js @@ -0,0 +1,22 @@ +import { Console } from '@woowacourse/mission-utils'; +import PROMPT_MESSAGES from '../constants/promptMessages.js'; + +class OutputView { + printResultHeader() { + Console.print(PROMPT_MESSAGES.RESULT_HEADER); + } + + printStepResult(result) { + result.forEach((car) => { + const [name, distance] = car; + Console.print(`${name} : ${'-'.repeat(distance)}`); + }); + Console.print(''); + } + + printWinners(winners) { + Console.print(`${PROMPT_MESSAGES.FINAL_WINNERS_PREFIX} : ${winners.join(', ')}`); + } +} + +export default OutputView;