Skip to content
66 changes: 65 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,65 @@
# javascript-calculator-precourse
# javascript-calculator-precourse
# 학습 목표
- `개발 환경`에 익숙해진다. (git, vscode, node)
- `문자열 덧셈 계산기 문제`를 해결한다.
# 개요
### 개발 환경
- 미션 저장소를 포크하여 개발을 진행할 개인 저장소를 만든다.
- `git clone`으로 개인 저장소에 대한 로컬 저장소를 만든다.
- 하나의 기능을 단위로 커밋을 생성한다. (add -> commit -> push)
커밋 메세지는 AngularJS Git Commit Message Conventions를 기준으로 작성한다.
### 문자열 덧셈 계산기
`입력받은 문자열에서 구분자를 기준으로 숫자를 분리해 덧셈 기능을 제공하는 계산기`
- 쉼표(,) 또는 콜론(:)을 구분자로 가지는 문자열을 전달하는 경우 구분자를 기준으로 분리한 각 숫자의 합을 반환한다.
예: "" => 0, "1,2" => 3, "1,2,3" => 6, "1,2:3" => 6
- 앞의 기본 구분자(쉼표, 콜론) 외에 커스텀 구분자를 지정할 수 있다. 커스텀 구분자는 문자열 앞부분의 "//"와 "\n" 사이에 위치하는 문자를 커스텀 구분자로 사용한다.
- 예를 들어 "//;\n1;2;3"과 같이 값을 입력할 경우 커스텀 구분자는 세미콜론(;)이며, 결과 값은 6이 반환되어야 한다.
사용자가 잘못된 값을 입력할 경우 "[ERROR]"로 시작하는 메시지와 함께 Error를 발생시킨 후 애플리케이션은 종료되어야 한다.
# 설계
## 흐름
```mermaid
flowchart LR
A[입력] --> B[파싱] --> C[검증] --> D[연산] --> E[출력]
```
## 기능 목록(커밋 기준)
- [x] 입력
- [x] @woowacourse/mission-utils에서 제공하는 Console API를 사용하여 입력을 받는다.
- [x] 문자열의 좌우 공백을 없앤다.
- [x] 파싱
- [x] 구분자 식별
- `기본 구분자`(`,`, `:`)
- 문자열 앞부분의 "//"와 "\n"가 존재하면 그 사이의 문자를 `커스텀 구분자`로 추가한다.
- [x] 숫자 추출
- 구분자를 통해 문자열의 숫자를 추출한다.
- [x] 검증 & 예외처리
- [x] 숫자들이 대해 0이나 양의 정수인지 검증한다. (다른 문자, 음수, 소수 x)
- [x] 구분자의 공백이 아닌 길이가 1인 하나의 문자인지 검증한다.
- [x] 구분자가 숫자가 아님을 검증한다.
- [x] 커스텀 구분자가 하나임을 검증한다.(여러 번 지정 불가능)
- [x] 연산(덧셈)
- [x] 숫자들을 모두 더한다.
- [x] 출력
- [x] @woowacourse/mission-utils에서 제공하는 Console API를 사용하여 출력한다.
```
결과 : 6
```


# 문제해결과정
### 설계 단계
> 파싱과 검증의 순서
- 해결해야하는 문제: 파싱과 검증의 순서를 설계하는 과정에서, 검증해야할 예외들 중에 빈문자열과 같이 파싱을 하지 않아도 할 수 있는 부분들을 어떻게 처리할 지 고민이 되었습니다.
- 어떻게 해결헀는지: `사전 검증`과 파싱 후`구조 검증`으로 나누면 불필요한 연산을 줄이고 안정적으로 처리할 수 있을 것이라고 생각합니다.
> 커스텀 구분자의 다양한 반례
- 해결해야하는 문제 : 커스텀 구분자에 대한 제한에 따라 다양한 예외들이 나오게 됩니다. 구분자의 종류, 개수에 따라 다양한 상황(`.` 허용시 소수문제, `//`허용시 구분자의 모호성)이 발생합니다.
- 어떻게 해결했는지: 명확성과 안전을 위해서 엄격한 제한을 두도록 합니다. 소수 입력을 제한하여 공백과 숫자를 제외한 문자를 공백으로 허용합니다. 그리고 문자는 하나만 가능합니다.
### 구현 단계
> 테스트 용이성
- 해결해야하는 문제: 미션 전반에 거쳐 테스트를 해가며 안정적인 개발을 해보고 싶었습니다.
- 어떻게 해결했는지: 최근에 함수형 패러다임에 대해서 공부를 하고 있습니다. 그래서 run()과 같은 중심 함수에 사이드 이펙트 몰아두고 다른 함수들은 언제나 같은 입력에 대해서 같은 결과를 보여주는 순수함수를 구현하려고 노력했습니다. 그리고 기능들을 함수로 최대한 쪼개보았습니다. 많이 부족하지만 이러한 노력들이 실제로 테스트를 진행할 때 도움이 되었습니다.
> 구분자로 숫자 분리
- 해결해야하는 문제: 직관적으로 이해하기 쉬운 forEach를 통해서 각 구분자로 숫자를 분리하려고 하는데, map을 사용하니 다음과 같은 오류가 발생헀습니다.
```jsx
TypeError: el.split is not a function
```
- 어떻게 해결했는지: 1차원 배열로 만드는 게 핵심이라고 생각해, mdn 탐색을 통해서 `flatMap`을 알게 되었습니다. 이를 통해 평탄화를 할 수 있었습니다.
16 changes: 16 additions & 0 deletions __tests__/ApplicationTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,19 @@ describe("문자열 계산기", () => {
await expect(app.run()).rejects.toThrow("[ERROR]");
});
});

describe("Parser, App 클래스 통합 테스트", () => {
test("입력값 파싱", async () => {
const inputs = "//;\\n1;2;3";
const app = new App();
const integrateResult = app.parser.parseExpressionToNumberList(inputs);
expect(integrateResult).toStrictEqual([1,2,3])
});
test("입력값 파싱", async () => {
const inputs = "//;\\n1;2;3";
const app = new App();
const integrateResult = app.parser.parseExpressionToNumberList(inputs);
const answer = app.accumulateNumbers(integrateResult);
expect(answer).toStrictEqual(6)
});
});
37 changes: 37 additions & 0 deletions __tests__/InputTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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);
});
};

describe("문자열 계산기 입력", () => {
test("입력이 들어오는 지 확인", async () => {
const inputs = ["1,2,3"];
mockQuestions(inputs);
const app = new App();
const testingInput = await app.getInputUsingWoowaMissionUtils("덧셈할 문자열을 입력해 주세요.");

expect(testingInput).toBe("1,2,3");
});
test("좌우 공백이 존재하는 입력이 그대로 들어오는 지 확인", async () => {
const inputs = [" 1,2,3 "];
mockQuestions(inputs);
const app = new App();
const testingInput = await app.getInputUsingWoowaMissionUtils("덧셈할 문자열을 입력해 주세요.");

expect(testingInput).toBe(" 1,2,3 ");
});
test("입력된 값이 비어있는지 검증", async () => {
const errMsg = "[ERROR] 아무것도 입력되지 않았습니다.";
const app = new App();
expect(() => app.checkStringIsEmpty(null, errMsg)).toThrow("[ERROR] 아무것도 입력되지 않았습니다.");
expect(() => app.checkStringIsEmpty(" ", errMsg)).toThrow("[ERROR] 아무것도 입력되지 않았습니다.");
expect(() => app.checkStringIsEmpty(undefined, errMsg)).toThrow("[ERROR] 아무것도 입력되지 않았습니다.");
});
});
72 changes: 72 additions & 0 deletions __tests__/ParserTest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import Parser from "../src/Parser"
describe("Parser 구분자 추출", () => {
test("정상적인 커스텀 구분자 추출 확인", () => {
const parser = new Parser()
const deli = parser.extractCustomDelimiterStrictly("//&\\n123")
expect(deli).toEqual("&");
});
test("비정상적인 커스텀 구분자(2자 이상) 에러 발생 확인", () => {
const parser = new Parser()
expect(() =>
parser.extractCustomDelimiterStrictly("//&#\\n123")
).toThrow("[ERROR]");
});
test("비정상적인 커스텀 구분자(숫자) 에러 발생 확인", () => {
const parser = new Parser()
expect(() =>
parser.extractCustomDelimiterStrictly("//3\\n123")
).toThrow("[ERROR]");
});
test("비정상적인 커스텀 구분자(구분자가 여러개) 에러 발생 확인", () => {
const parser = new Parser()
expect(() =>
parser.extractCustomDelimiterStrictly("//%\\n1:2//&\\n3")
).toThrow("[ERROR]");
});
test("커스텀 구분자가 존재하지 않는 경우 에러를 발생하지 않아야 한다.", () => {
const parser = new Parser()
const deli = parser.extractCustomDelimiterStrictly("1,2,3")
expect(deli).toEqual(null);
});
test("구분자 리스트 추가 확인", () => {
const parser = new Parser();
parser.delimiterSet.add("&");
expect(parser.delimiterSet).toStrictEqual(new Set([",", ":", "&"]));
});
test("커스텀 구분자 추출 후, 구분자 리스트 추가 확인", () => {
const parser = new Parser();
parser.parseExpressionToNumberList("//&\\n1&2&3");
expect(parser.delimiterSet).toStrictEqual(new Set([",", ":", "&"]));
});
});

describe("Parser 숫자 분리", () => {
test("기본 구분자 숫자 분리(단일)", () => {
const parser = new Parser();
const result = parser.splitByDelimitersToNumbers("1,2,3", parser.delimiterSet);
expect(result).toStrictEqual([1, 2, 3])
});
test("기본 구분자 숫자 분리(혼합)", () => {
const parser = new Parser();
const result = parser.splitByDelimitersToNumbers("1,2:3", parser.delimiterSet);
expect(result).toStrictEqual([1, 2, 3])
});
test("커스텀구분자 숫자 분리(단일)", () => {
const parser = new Parser();
parser.delimiterSet.add("(");
const result = parser.splitByDelimitersToNumbers("1(2(3", parser.delimiterSet);
expect(result).toStrictEqual([1, 2, 3])
});
test("커스텀구분자 숫자 분리(기본 구분자와 혼합)", () => {
const parser = new Parser();
parser.delimiterSet.add("(");
const result = parser.splitByDelimitersToNumbers("1:2(3", parser.delimiterSet);
expect(result).toStrictEqual([1, 2, 3])
});
test("잘못된 숫자 검증(소수, 음수)", () => {
const parser = new Parser();
expect(() => parser.splitByDelimitersToNumbers("1:2.4:3", parser.delimiterSet)).toThrow("[ERROR]")
expect(() => parser.splitByDelimitersToNumbers("1:-3:3", parser.delimiterSet)).toThrow("[ERROR]")
});

})
30 changes: 29 additions & 1 deletion src/App.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
import { Console } from "@woowacourse/mission-utils";
import Parser from "./Parser";

class App {
async run() {}
constructor() {
this.parser = new Parser();
}
async run() {
const rawInput = (await this.getInputUsingWoowaMissionUtils("덧셈할 문자열을 입력해 주세요.")).trim(); // 계산해야할 문자열
this.checkStringIsEmpty(rawInput, "[ERROR] 아무것도 입력되지 않았습니다."); // 빈 입력값에 대한 사전검증
const parsedNumbers = this.parser.parseExpressionToNumberList(rawInput);
const calculatedValue = this.accumulateNumbers(parsedNumbers);
this.printOutputUsingWoowaMissionUtils("결과 : ", calculatedValue)
}

async getInputUsingWoowaMissionUtils(questionString) { // @woowacourse/mission-utils의 Console.readLineAsync 함수로 비동기 입력 받기
return await Console.readLineAsync(questionString);
}

checkStringIsEmpty(str, errMsg) { // 내용과 상관없는 빈 string 자체에 대한 검증 함수
if(!str || str.trim() === "") throw new Error(errMsg);
}

accumulateNumbers(numberArray) {
return numberArray.reduce((acc, cur) => acc + cur)
}

printOutputUsingWoowaMissionUtils(outMsg, output) {
Console.print(outMsg + output);
}
}

export default App;
52 changes: 52 additions & 0 deletions src/Parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
class Parser {
constructor(expression) {
// 기본 구분자와 커스텀 구분자 사이에 구별이 필요하지 않기 때문에 Map 보다는 중복을 제거할 수 있는 Set가 더 적합하다고 판단했습니다.
this.delimiterSet = new Set([",", ":"]);
}

extractCustomDelimiterStrictly(expr) { // 커스텀 구분자를 추출(1.구분자는 하나의 문자이며, 2.하나의 종류만 존재한다.)
const matchedDelimiterArray = expr.match(/\/\/(.*)\\n/); // 정규식으로 커스텀 구분자 필터
if(!matchedDelimiterArray) return null // 1) 사용자가 커스텀 구분자를 지정하지 않은 경우 그대로 return

const requestedDelimiter = matchedDelimiterArray[1]; // 사용자가 요청한 구분자 추출
// 커스텀 구분자 포맷을 가진 경우만 검증
this.validateCustomDelimiterExpression(expr, requestedDelimiter);
return requestedDelimiter; // 2) 커스텀 구분자가 있는 경우 추출하여 해당 구분자 반환
}

validateCustomDelimiterExpression(origin, target) {
if(!origin.startsWith("//") || target.length != 1 || !isNaN(target))
throw new Error("[ERROR] 잘못된 구분자입니다.");
}

splitByDelimitersToNumbers(rawNumbers, delimiterSet) { // 구분자와 섞인 숫자 뭉치를 분리
let numberArray= [rawNumbers];
delimiterSet.forEach(deli => { // 각 구분자로 분리
numberArray = numberArray.flatMap(el => el.split(deli));
});
numberArray = numberArray.map(num => { // string -> num과 검증
num = Number(num);
this.validateBusinessRuleNumber(num);
return num;
});
return numberArray;
}

validateBusinessRuleNumber(num) { // 0을 포함한 양의 정수
if(!Number.isInteger(num) || num < 0)
throw new Error("[ERROR] 옳지 않은 숫자입니다.(0과 양의 정수만 가능)")
}

parseExpressionToNumberList(expression) {
let targetExpr = expression
const customDelimiter = this.extractCustomDelimiterStrictly(targetExpr); // 커스텀 구분자 추출
if(customDelimiter) {
this.delimiterSet.add(customDelimiter); // 커스텀 구분자 추가
targetExpr = targetExpr.slice(5); // 커스텀 헤더 제거하여 숫자본체만 분리
}
const parsedNumberList = this.splitByDelimitersToNumbers(targetExpr, this.delimiterSet); // 구분자로 각 숫자 분리
return parsedNumberList;
}
}

export default Parser;