diff --git a/README.md b/README.md index e078fd41..8b8a89cb 100644 --- a/README.md +++ b/README.md @@ -1 +1,97 @@ # javascript-racingcar-precourse +# 학습 목표 +- 여러 역할을 하는 큰 함수를 단일 역할을 하는 작은 함수로 분리한다. +- 테스트 도구를 사용하는 방법을 배우고 프로그램이 제대로 작동하는 지 테스트 한다. +- 1주차 피드백을 반영한다. +# 요구사항 정리 +### 1주차 피드백 정리 +- git 명령어, git 자원, 관리 파일(package.json, package-lock.json 등) 역할 학습 +- 디버거 사용에 익숙해지기 +- 좋은 이름 짓기 +- 정확한 공백 사용 +- 의미 없는 주석 달지 않기 +- JavaScript API 적극 활용 +### 기능 요구사항 +자동차 경주 게임을 구현 +- 사용자는 자동차의 이름과 몇 번 이동할 것 인지 입력할 수 있다. + - 이름에 대한 입력은 쉼표를 기준으로 구분하며, 각 자동차 명은 5자 이하 +- 주어진 횟수 동안 전진/정지 할 수 있다. + - 0~9 무작위 값이 4 이상일 경우 전진 / 4 미만 정지 +- 자동차 경주 게임이 종료된 후 누가 우승했는지를 알려준다. + - 우승자가 여러 명일 경우 쉼표(,)를 이용하여 구분 +- 사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시킨 후 애플리케이션은 종료된다. +### 프로그래밍 요구사항 +- 함수가 한가지 일만 하도록 작게 만들기 + - 들어쓰기 깊이 2까지만 허용 +- 3항 연산자 사용 X +- Jest로 테스트 코드 작성하여 기능 정상 작동 확인 + - Jest 활용법 공부하기 + +# 설계 +## 함수 구조(구현 전 설계) +```mermaid +flowchart TD + %% 최상위 (부수효과 담당) + main["App.run()"] + %% 핵심 로직 (순수) + main --> Race + + subgraph Race["Race (순수 함수 계층)"] + direction TB + R1["runEntireRace()"] --> R2["moveCarsOneLap()"] + R2 --> R3["decideCarMoveOrNot()"] + R1 --> R4["applySingleLapResult()"] + R1 --> R6["selectWinningCars()"] + end + + %% 입력 처리 + main --> input["getInputUsingMissionUtils()"] + %% 결과 출력 + main --> output["printOutputUsingMissionUtils()"] + %% 결과 렌더링 + main --> render["renderHistory()"] + render --> output + + %% 외부 난수 API + main --> random["makeRandomNumberArray()"] +``` +## 기능 목록 +- [x] 입력 + - [x] 자동차 명 입력 받기 + - [x] `,` 기준으로 자동차명을 구분 + - [x] 진행 랩 수(몇번 진행할 것인지) 입력 받기 +- [x] 입력값 검증(예외) + - [x] 사용자가 잘못된 값을 입력할 경우 `[ERROR]`로 시작하는 문구와 함께 에러를 발생시키고 프로그램을 종료시킨다. + - [x] 각 자동차명은 공백을 포함하지 않은 5자 이하의 문자열이다. + - [x] 각 자동차명은 중복을 허용하지 않는다. + - [x] 랩 수는 양의 정수이다. +- [x] 랜덤 넘버 데이터 생성 + - [x] `자동차 수 * 랩 수` 만큼의 랜덤 숫자(0~9사이 정수) 생성 +- [x] 레이스 진행(전진 or 정지) + - [x] 랜덤 숫자가 4 이상(4,5,6,7,8,9)이면 전진, 4미만(0,1,2,3)이면 정지 +- [x] 우승자 결정 + - [x] 가장 많이 전진한 자동차가 우승자로 결정된다. + - [x] 공동 우승도 인정된다. +- [x] 레이스 히스토리 출력 + - 각 랩 순으로 레이스 기록을 출력한다. +- [x] 우승자명 출력 + - 동률의 경우 `,`로 구분하여 출력한다. + +# 문제 해결 과정 +### 설계 단계 +> OOP + FP(함수형 프로그래밍) +- 해결해야하는 문제: 프리코스 기간 동안 직접 함수형 패러다임을 공부하고 적용을 시도해보고 있습니다. 함수형 패러다임을 OOP에 융합 시키기 위한 구조 설계에 어려움을 겪고 있습니다. +- 어떻게 해결했는지: 큰 틀에서 보면 `App 클래스`가 `부수효과(입출력, API)`를 모두 담당하고 레이스를 진행하는 부분은 순수함수로 작성하려고 합니다. 이를 바탕으로 함수명 구조를 짜보았는데, 아마 구현을 진행하면서 많은 수정이 있을 것 같습니다. +> Car 클래스 분리 +- 해결해야하는 문제: Car을 객체로 만들어서 관리한다는 아이디어가 자연스레 떠올랐습니다. 의미적으로 확실하게 분리가 되니까 관리와 가독성에 장점이 있다고 생각합니다. 하지만 자동차가 복잡도 높은 기능을 수행하는 것도 아니기 때문에 고민이 되었습니다. +- 어떻게 해결했는지: 클래스보다는 객체와 같은 구조로 관리를 하여 함수형 중심적으로 설계를 했습니다. +> 랜덤 넘버 생성 +- 해결해야하는 문제: 함수형 패러다임의 적용과 연결되는 부분입니다. API를 통해서 생성하는 난수들을 어떻게 생성하고 사용할 지에 대한 문제입니다. 처음에는 별 생각없이 매 라운드마다 난수를 만들어서 판별하도록 하면 되겠다고 생각했지만 설계를 하다보니 레이스마다 매번 난수를 생성하면 순수성을 유지하지 못하고, 테스트에도 어려움이 있을 것이라 판단했습니다. +- 어떻게 해결했는지: App 단에서 필요한 개수의 난수를 모두 생성하여 넘겨주는 방식으로 문제를 해결해보려 합니다. 마찬가지로 매 랩읙 결과를 모두 끝이 나고 한 번에 순서대로 출력하는 방식으로 진행하려고 합니다. +### 구현 단계 +> 데이터 구조 +- 해결해야하는 문제: 난수 테이프 부분에서 필요한 모든 난수를 미리 만들어서 레이스를 진행하는 흐름을 처음 구현하다보니 데이터 구조 결정에 문제있었습니다. 기능 중심으로만 설계하다 보니, 각 함수가 어떤 데이터를 주고받을지에 대한 구조적 고려가 부족했던 것이 원인이었습니다. +- 어떻게 해결했는지: 처음에는 단순히 HashMap을 사용하여 <자동차명-히스토리 배열>을 이루면 쉽게 구현애 가능하겠구나 생각했습니다. 여러 데이터를 파라미터로 넘겨주다 보니 시간복잡도와 메모리 비용이 너무 크게 발생함을 깨닫고, 2차원 배열로 히스토리를 저장하는 방식으로 문제를 해결했습니다. +> FIFO vs LIFO +- 해결해야하는 문제: 모든 테스트 코드 중 제공된 테스트만 통과하지 못하는 문제가 있었습니다. +- 어떻게 해결했는지: 난수를 생성해서 배열에 push함수를 사용해서 난수테이프를 만들었기에, 자연스레 pop()을 사용했던 것이 문제였습니다. 먼저 push된 것을 먼저 사용할 수 있도록 shift()를 사용하여 문제를 해결했습니다. \ No newline at end of file diff --git a/__tests__/APIsTest.js b/__tests__/APIsTest.js new file mode 100644 index 00000000..304bf878 --- /dev/null +++ b/__tests__/APIsTest.js @@ -0,0 +1,63 @@ +import App from "../src/App.js"; +import { MissionUtils } from "@woowacourse/mission-utils"; + +const mockQuestions = (inputs) => { + MissionUtils.Console.readLineAsync = jest.fn(); + + MissionUtils.Console.readLineAsync.mockImplementation(() => { + const input = inputs.shift(); + return Promise.resolve(input); + }); +}; + +const getLogSpy = () => { + const logSpy = jest.spyOn(MissionUtils.Console, "print"); + logSpy.mockClear(); + return logSpy; +}; + +describe("woowacourse/mission-utils api 테스트", () => { + let app; + beforeEach(() => { + app = new App(); + }) + + test.each([ + ["경주할 자동차 이름을 입력해주세요.", "a,b,c"], + ["경주할 자동차의 이름을 입력해주십쇼.","hihi, woowa, pre"], + ["시도할 횟수는 몇 회인가요?", "5"] + ])("readLineAsync가 입력값(자동차명 or 랩 수)을 정상적으로 처리되어 가져온다.", async (question, answer) => { + mockQuestions([answer]); + const userReply = await app.readInputAsyncUsingWoowaMissionApi(question); + expect(MissionUtils.Console.readLineAsync).toHaveBeenCalledWith(question); + expect(userReply).toBe(answer); + }); + + test("Random.pickNumberInRange를 사용해 0 이상 9 이하의 정수를 생성한다", () => { + const results = Array.from({ length: 100 }, () => + app.pickRandomNumberInRangeUsingWoowaMissionApi(0, 9) + ); + + for (const num of results) { + expect(Number.isInteger(num)).toBe(true); + expect(num).toBeGreaterThanOrEqual(0); + expect(num).toBeLessThanOrEqual(9); + } + }); + + test("Console.print를 사용해 레이스의 히스토리를 출력한다.", () => { + const logs = ["a : -", "b : ", "a : --", "a : --\nb : -\nc : -"] + const logSpy = getLogSpy(); + app.printHistoryOfRace([[1, 0, 1], [1, 1, 1], [2, 1, 1]], ["a", "b", "c"]); + logs.forEach((log) => { + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }); + }); + + test("Console.print를 사용해 우승자를 출력한다.", () => { + const log = "최종 우승자 : a, b"; + const logSpy = getLogSpy(); + app.printWinnerOfRace(["a", "b"]); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); + }) +}) \ No newline at end of file diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 0260e7e8..843a815d 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -45,7 +45,14 @@ describe("자동차 경주", () => { expect(logSpy).toHaveBeenCalledWith(expect.stringContaining(log)); }); }); - + test.each(([ + [["a", "b", "c"], 4, 12], + [["a", "b", "c", "d"], 1, 4] + ]))("필요한 만큼(차량 수 * 랩 수)의 난수 테이프 생성", (cars, laps, expected) => { + const app = new App(); + const randomNumberTape = app.makeRandomNumbersTape(cars, laps); + expect(randomNumberTape.length).toBe(expected); // cars.length * laps + }); test("예외 테스트", async () => { // given const inputs = ["pobi,javaji"]; diff --git a/__tests__/DomainTest.js b/__tests__/DomainTest.js new file mode 100644 index 00000000..fa8cf5c9 --- /dev/null +++ b/__tests__/DomainTest.js @@ -0,0 +1,22 @@ +import { runEntireRace } from "../src/domains/race"; +import { determineWinnerOfRace, findMaxValue } from "../src/domains/queries"; + +describe("레이스 진행 도메인 테스트", () => { + test.each([ + [["a", "b", "c"], 2, [4, 1, 2, 9, 0, 4], [[1, 0, 0], [2, 0, 1]]], + [["red", "blue", "black"], 3, [5, 2, 4, 9, 5, 9, 9, 4, 6], [[1, 0, 1], [2, 1, 2], [3, 2, 3]]] + ])("레이스 진행하여 전체 히스토리를 기록한다.", (carnames, laps, randomTape, raceHistory) => { + const result = runEntireRace(carnames, laps, randomTape); + expect(result).toStrictEqual(raceHistory); + }) + test("숫자 배열에서 최댓값을 찾는다.", () => { + expect(findMaxValue([2, 3, 8])).toBe(8); + }) + test.each([ + [["a", "ab", "c"], [2, 5, 4], ["ab"]], + [["red", "black", "blue"], [9, 19, 19], ["black", "blue"]] + ])("최종 점수 배열에서 우승자를 선택한다.", (names, scores, expectWinner) => { + const winner = determineWinnerOfRace(names, scores); + expect(winner).toStrictEqual(expectWinner); + }) +}) \ No newline at end of file diff --git a/__tests__/UtilTest.js b/__tests__/UtilTest.js new file mode 100644 index 00000000..bc87036e --- /dev/null +++ b/__tests__/UtilTest.js @@ -0,0 +1,31 @@ +import { parseByComma } from "../src/utils/parsing"; +import { validateCarNameRule, validateLapNumberRule } from "../src/utils/validator"; + +describe("유틸 함수 테스트", () => { + test.each(([ + ["a,b,c", ["a", "b", "c"]], + ["ab,cd,e,f ", ["ab", "cd", "e", "f"]], + [" a , b, c, d ", ["a", "b", "c", "d"]], + ["a,b,,d", ["a", "b", "", "d"]] + ]))("쉼표를 기준으로 문자열을 파싱해주는 parseByComma 테스트", (input, parsed) => { + const parsingInput = parseByComma(input); + expect(parsingInput).toStrictEqual(parsed); + }); + + test.each(([ + [["a", "aaaa", "bbbbbb"], "[ERROR] : 자동차 명은 5자 이하여야 합니다."], + [["a", "a", "ab"], "[ERROR] : 중복된 이름을 사용할 수 없습니다."] + ]))("자동차명 검증 테스트", (carNameHasErr, errMsg) => { + expect(() => validateCarNameRule(carNameHasErr)).toThrow(errMsg); + }); + + test.each(([ + [2.1, "[ERROR] : 횟수는 양의 정수이어야 합니다."], + [-1, "[ERROR] : 횟수는 양의 정수이어야 합니다."], + [NaN, "[ERROR] : 횟수는 양의 정수이어야 합니다."], + [null, "[ERROR] : 횟수는 양의 정수이어야 합니다."], + [undefined, "[ERROR] : 횟수는 양의 정수이어야 합니다."], + ]))("횟수 검증 테스트", (numberHasErr, errMsg) => { + expect(() => validateLapNumberRule(numberHasErr)).toThrow(errMsg); + }) +}) \ No newline at end of file diff --git a/src/App.js b/src/App.js index 091aa0a5..f4db2019 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,51 @@ -class App { - async run() {} -} +import { MissionUtils } from "@woowacourse/mission-utils"; +import { parseByComma, parseToHistoryFormat, parserToWinnerFormat } from "./utils/parsing"; +import { runEntireRace } from "./domains/race"; +import { determineWinnerOfRace } from "./domains/queries"; +import { validateCarNameRule, validateLapNumberRule } from "./utils/validator"; +class App { + async run() { + // 입력(자동차명, 횟수) + const stringOfCarNamesUserRequest = await this.readInputAsyncUsingWoowaMissionApi("경주할 자동차 이름을 입력하세요.(이름은 쉼표(,) 기준으로 구분)"); + const arrayOfCarNamesUserRequest = parseByComma(stringOfCarNamesUserRequest); + validateCarNameRule(arrayOfCarNamesUserRequest); + const stringOfLapUserRequest = await this.readInputAsyncUsingWoowaMissionApi("시도할 횟수는 몇 회인가요?"); + const countOfLapUserRequest = Number(stringOfLapUserRequest); + validateLapNumberRule(countOfLapUserRequest); + // 레이스 진행 + const randomNumberTape = this.makeRandomNumbersTape(arrayOfCarNamesUserRequest, countOfLapUserRequest); + const historyOfRace = runEntireRace(arrayOfCarNamesUserRequest, countOfLapUserRequest, randomNumberTape); + const namesOfWinner = determineWinnerOfRace(arrayOfCarNamesUserRequest, historyOfRace[historyOfRace.length -1]); + // 결과 출력(레이스 히스토리, 우승자) + this.printHistoryOfRace(historyOfRace, arrayOfCarNamesUserRequest); + this.printWinnerOfRace(namesOfWinner); + } + + async readInputAsyncUsingWoowaMissionApi(questionStr) { + return await MissionUtils.Console.readLineAsync(questionStr); + } + makeRandomNumbersTape(cars, laps) { // 부수효과를 없애기 위해 필요한 만큼의 난수를 만들고 레이스 진행 + let numberTape = []; + const countOfNeededNumber = cars.length * laps; + for(let cnt = 0; cnt < countOfNeededNumber; cnt++) { + numberTape.push(this.pickRandomNumberInRangeUsingWoowaMissionApi(0, 9)) + } + return numberTape; + } + pickRandomNumberInRangeUsingWoowaMissionApi(min, max) { + return MissionUtils.Random.pickNumberInRange(min, max); + } + printOutputUsingWoowaMissionApi(output) { + MissionUtils.Console.print(output); + } + printHistoryOfRace(history, name) { + history.forEach(log => { + this.printOutputUsingWoowaMissionApi(parseToHistoryFormat(log, name)) + }) + } + printWinnerOfRace(winner) { + this.printOutputUsingWoowaMissionApi(parserToWinnerFormat(winner)); + } +}; export default App; diff --git a/src/domains/queries.js b/src/domains/queries.js new file mode 100644 index 00000000..db94d32d --- /dev/null +++ b/src/domains/queries.js @@ -0,0 +1,8 @@ +export const findMaxValue = (values) => Math.max(...values); // 명확성과 재사용성이 높아보여 분리 + +export function determineWinnerOfRace(carNames, finalScores) { + const scoreOfWinner = findMaxValue(finalScores); + const winner = carNames.filter((name, carIdx) => finalScores[carIdx] === scoreOfWinner) + return winner; +} + diff --git a/src/domains/race.js b/src/domains/race.js new file mode 100644 index 00000000..57f8d4e8 --- /dev/null +++ b/src/domains/race.js @@ -0,0 +1,17 @@ +export const meetMoveCondition = (number, threshold = 4) => number >= threshold; // 요구사항: 4이상이면 전진, 아니면 정지 + +export function runEntireRace(arrOfCarNames, cntOfLaps, randomTape) { + let randomNumbers = randomTape.slice(); // 불변성 방지 복사 + let scoreAfterCurrentLap = Array(arrOfCarNames.length).fill(0); + const raceHistoryByLap = []; // 최종결과(마지막랩)로 쉽게 활용할 수 있도록 랩을 기준으로 데이터 저장 + for(let currentLap = 0 ; currentLap < cntOfLaps ; currentLap++) { + scoreAfterCurrentLap = scoreAfterCurrentLap.slice().map(prev => { + if(meetMoveCondition(randomNumbers.shift())) return prev + 1; + return prev; + }); + raceHistoryByLap.push(scoreAfterCurrentLap.slice()); + } + return raceHistoryByLap; +}; + + diff --git a/src/utils/parsing.js b/src/utils/parsing.js new file mode 100644 index 00000000..274f0501 --- /dev/null +++ b/src/utils/parsing.js @@ -0,0 +1,13 @@ +export function parseByComma(input) { + return input.split(",").map(n => n.trim()); +} + +export function parseToHistoryFormat(oneRoundHistory, name) { // [1,1,1] ["a","b","c"] + return oneRoundHistory + .map((result, idx) => `${name[idx]} : ${"-".repeat(result)}`) + .join("\n"); +} + +export function parserToWinnerFormat(winner) { + return `최종 우승자 : ${winner.join(", ")}`; +} \ No newline at end of file diff --git a/src/utils/validator.js b/src/utils/validator.js new file mode 100644 index 00000000..2937951b --- /dev/null +++ b/src/utils/validator.js @@ -0,0 +1,11 @@ +export function validateCarNameRule(names) { + if(new Set(names).size != names.length) throw new Error("[ERROR] : 중복된 이름을 사용할 수 없습니다.") + names.forEach(n => { + if(n.length > 5 || n.length === 0) throw new Error("[ERROR] : 자동차 명은 5자 이하여야 합니다."); + }); +} + +export function validateLapNumberRule(lap) { + if(lap <= 0 || !Number.isInteger(lap)) + throw new Error("[ERROR] : 횟수는 양의 정수이어야 합니다.") +} \ No newline at end of file