diff --git a/README.md b/README.md index 13420b29..76f9d6f9 100644 --- a/README.md +++ b/README.md @@ -1 +1,57 @@ -# javascript-calculator-precourse \ No newline at end of file +# javascript-calculator-precourse + +# 문자열 덧셈 계산기 개발 + +## 1. 기능요구 사항 + +-[X] 커스텀 구분자를 추출하는 기능 -[X] 구분자를 통해 입력받은 숫자를 파싱하는 기능 -[X] 파싱된 입력이 올바른지 확인하는 기능 -[X] 파싱된 입력을 더하는 기능 + +- Model +- Parser : 구분자 +- Number : 입력받은 숫자 +- Extraction : 추출자 +- RandomMaker : 랜덤 제작기 + +- View +- InputView : 입력 +- OutputView : 출력 + +- Controller +- Calculator : 계산기 + +### - 입력 요구사항 + +-[X] 구분자와 야수로 구성된 문자열 -[X] 구분자 : `,` `:` `//커스텀구분자\n` + +#### - 추가사항 + +-[X] 음수 입력시 ERROR 처리 + +### - 출력 요구사항 + +-[X] "결과 : {int}" -[X] 잘못된 입력시 [ERROR]로 시작하는 메시지와 함께 종료 + +## 2. 프로그래밍 요구 사항 + +-[X] 프로그래밍의 시작지점은 App.js의 run() 인가? -[X] package.json 파일을 변경하지 않았는가? -[X] 프로그램 종료시 process.exit()를 호출하지 않았는가? -[X] 자바스크립트 코드 컨벤션에 맞게 작성했는가? -[X] @woowacourse/mission-utils에서 제공하는 Console API를 사용해서 구현했는가? + +## 3. 도전 사항 + +-[X] TDD 설계원칙 적용 -[X] 테스트 코드 랜덤 문자열 생성기 구현 -[X] MVC 패턴 적용하기 -[X] 객체지향 원칙 준수하기 -[X] AI 사용 없이 구현하기 + +## 4. 예상 실행 결과 + +실행 결과 예시 +덧셈할 문자열을 입력해 주세요. +1,2:3 +결과 : 6 + +### 5. 추가사항 + +- 연속된 구분자의 입력을 허용할것인가 - 미허용 +- 공백 입력을 허용할것인가 - 미허용 +- 소수점을 허용할것인가 - 미허용 + -> 소수점이 포함된경우 커스텀 구분자로 . 이 오면 허용할것인가 +- 커스텀 구분자 여러개를 허용할것 인가 ? - 허용 +- 커스텀 구분자가 중간에 나오는것을 허용할것인가? - 미 허용 +- 커스텀 구분자가 . 일때 .. 처리를 어케할것인지 - 미 허용 구분자는 한글자 diff --git a/__tests__/ApplicationTest.js b/__tests__/ApplicationTest.js index 7c6962dd..6cbe8579 100644 --- a/__tests__/ApplicationTest.js +++ b/__tests__/ApplicationTest.js @@ -1,5 +1,5 @@ -import App from "../src/App.js"; -import { MissionUtils } from "@woowacourse/mission-utils"; +import { MissionUtils } from '@woowacourse/mission-utils'; +import App from '../src/App.js'; const mockQuestions = (inputs) => { MissionUtils.Console.readLineAsync = jest.fn(); @@ -11,18 +11,18 @@ const mockQuestions = (inputs) => { }; const getLogSpy = () => { - const logSpy = jest.spyOn(MissionUtils.Console, "print"); + const logSpy = jest.spyOn(MissionUtils.Console, 'print'); logSpy.mockClear(); return logSpy; }; -describe("문자열 계산기", () => { - test("커스텀 구분자 사용", async () => { - const inputs = ["//;\\n1"]; +describe('문자열 계산기', () => { + test('커스텀 구분자 사용', async () => { + const inputs = ['//;\\n1']; mockQuestions(inputs); const logSpy = getLogSpy(); - const outputs = ["결과 : 1"]; + const outputs = ['결과 : 1']; const app = new App(); await app.run(); @@ -32,12 +32,12 @@ describe("문자열 계산기", () => { }); }); - test("예외 테스트", async () => { - const inputs = ["-1,2,3"]; + test('예외 테스트', async () => { + const inputs = ['-1,2,3']; mockQuestions(inputs); const app = new App(); - await expect(app.run()).rejects.toThrow("[ERROR]"); + await expect(app.run()).rejects.toThrow('[ERROR]'); }); }); diff --git a/src/App.js b/src/App.js index 091aa0a5..b99509d6 100644 --- a/src/App.js +++ b/src/App.js @@ -1,5 +1,10 @@ +import Controller from './controller/Controller.js'; + class App { - async run() {} + async run() { + const controller = new Controller(); + await controller.run(); + } } export default App; diff --git a/src/constant/error.js b/src/constant/error.js new file mode 100644 index 00000000..a78a633d --- /dev/null +++ b/src/constant/error.js @@ -0,0 +1,11 @@ +export const ERROR_MESSAGE = { + NOT_MINUS: '[ERROR] 음수가 포함되어 있습니다.', + NOT_EMPTY: '[ERROR] 공백이 포함되어 있습니다.', + NOT_NUMBER: '[ERROR] 숫자가 아닌것이 포함되어 있습니다.', + DELIMITER_CONTINUOUS: '[ERROR] 연속된 구분자가 존재합니다.', + DELIMITER_EMPTY: '[ERROR] 구분자가 공백입니다.', + DELIMITER_HAS_WHITESPACE: '[ERROR] 구분자에 공백이 포함되어 있습니다.', + DELIMITER_HAS_NUMBER: '[ERROR] 구분자에 숫자가 포함되어 있습니다.', + DELIMITER_NOT_SINGLE_CHAR: '[ERROR] 구분자는 한 글자여야 합니다.', + NOT_INTEGER: '[ERROR] 소수가 포함되어 있습니다.', +}; diff --git a/src/constant/regex.js b/src/constant/regex.js new file mode 100644 index 00000000..24b7f8fb --- /dev/null +++ b/src/constant/regex.js @@ -0,0 +1,25 @@ +export const REGEX_PATTERNS = { + CUSTOM_DELIMITER_EXTRACT: /\/\/(.*?)\\n/g, + CUSTOM_DELIMITER_DEFINITION: /\/\/.+?\\n/g, + REGEX_SPECIAL_CHARS: /[.*+?^${}()|[\]\\]/g, + WHITESPACE: /\s/, + DIGIT: /\d/, +}; + +export const RegexUtils = { + escapeRegexChars(str) { + return str.replace(REGEX_PATTERNS.REGEX_SPECIAL_CHARS, '\\$&'); + }, + + createContinuousPattern(delimiter) { + return new RegExp(`${delimiter}{2,}`); + }, + + createSplitPattern(delimiters) { + return new RegExp(delimiters.join('|')); + }, + + createCusomPattern(delimiter) { + return `//${delimiter}\\n`; + }, +}; diff --git a/src/controller/Controller.js b/src/controller/Controller.js new file mode 100644 index 00000000..d4663211 --- /dev/null +++ b/src/controller/Controller.js @@ -0,0 +1,40 @@ +import Extraction from '../service/model/Extraction.js'; +import Parser from '../service/model/Parser.js'; +import inputView from '../view/InputView.js'; +import outputView from '../view/OutputView.js'; +import Number from '../service/model/Number.js'; +import validate from '../service/validate/validate.js'; + +export default class Controller { + constructor() { + this.extraction = new Extraction(); + this.parser = new Parser(); + } + + async run() { + try { + const input = + await inputView.readLineMessage('덧셈할 문자열을 입력해 주세요.'); + + // 구분자 추출 {raw:원본,escaped:정규식 이스케이프} + const { raw, escaped } = this.extraction.extractCustomDelimiters(input); + validate.validateDelimiters(raw); + + const textWithoutDefinitions = + this.parser.removeCustomDelimiterDefinitions(input, raw); + validate.validateContinuousDelimiters(textWithoutDefinitions, escaped); + + const numbers = this.parser.parseToNumbers( + textWithoutDefinitions, + escaped, + ); + validate.validateNumbers(numbers); + + const numberModel = new Number(numbers); + await outputView.printMessage(`결과 : ${numberModel.getAddedNumbers()}`); + } catch (error) { + await outputView.printMessage(error.message); + throw new Error(error.message); + } + } +} diff --git a/src/service/model/Extraction.js b/src/service/model/Extraction.js new file mode 100644 index 00000000..853ed04a --- /dev/null +++ b/src/service/model/Extraction.js @@ -0,0 +1,20 @@ +import { REGEX_PATTERNS, RegexUtils } from '../../constant/regex.js'; + +export default class Extraction { + extractCustomDelimiters(text) { + const delimiters = Array.from( + text.matchAll(REGEX_PATTERNS.CUSTOM_DELIMITER_EXTRACT), + (match) => match[1], + ); + return { + raw: delimiters, + escaped: this.#escapeDelimiters(delimiters), + }; + } + + #escapeDelimiters(delimiters) { + return delimiters.map((delimiter) => + RegexUtils.escapeRegexChars(delimiter), + ); + } +} diff --git a/src/service/model/Number.js b/src/service/model/Number.js new file mode 100644 index 00000000..1223e1e8 --- /dev/null +++ b/src/service/model/Number.js @@ -0,0 +1,11 @@ +export default class Number { + #numbers; + + constructor(numbers) { + this.#numbers = numbers; + } + + getAddedNumbers() { + return this.#numbers.reduce((acc, cur) => acc + cur); + } +} diff --git a/src/service/model/Parser.js b/src/service/model/Parser.js new file mode 100644 index 00000000..a46a43c6 --- /dev/null +++ b/src/service/model/Parser.js @@ -0,0 +1,27 @@ +import { RegexUtils } from '../../constant/regex.js'; + +const DEFAULT_DELIMITERS = [',', ':']; + +export default class Parser { + parseToNumbers(text, escapedDelimiters) { + const allDelimiters = [...escapedDelimiters, ...DEFAULT_DELIMITERS]; + const splitPattern = RegexUtils.createSplitPattern(allDelimiters); + return text + .split(splitPattern) + .filter((value) => value !== '') + .map((value) => Number(value)); + } + + removeCustomDelimiterDefinitions(text, delimiters) { + let result = text; + + delimiters.forEach((delimiter) => { + const definition = RegexUtils.createCusomPattern(delimiter); + if (result.startsWith(definition)) { + result = result.slice(definition.length); + } + }); + + return result; + } +} diff --git a/src/service/validate/validate.js b/src/service/validate/validate.js new file mode 100644 index 00000000..26f34d49 --- /dev/null +++ b/src/service/validate/validate.js @@ -0,0 +1,69 @@ +import { ERROR_MESSAGE } from '../../constant/error.js'; +import { REGEX_PATTERNS, RegexUtils } from '../../constant/regex.js'; + +const DEFAULT_DELIMITERS = [',', ':']; + +const validate = { + validateNumbers(numbers) { + numbers.forEach((number) => { + // 공백 체크 + if (typeof number === 'string' && number.trim() === '') { + throw new Error(ERROR_MESSAGE.NOT_EMPTY); + } + // 숫자 여부 체크 + if (Number.isNaN(Number(number))) { + throw new Error(ERROR_MESSAGE.NOT_NUMBER); + } + // 음수 체크 + if (number < 0) { + throw new Error(ERROR_MESSAGE.NOT_MINUS); + } + // 소수 체크 + if (!Number.isInteger(Number(number))) { + throw new Error(ERROR_MESSAGE.NOT_INTEGER); + } + }); + }, + + validateContinuousDelimiters(inputText, escapedDelimiters) { + // 커스텀 구분자 정의 부분 제거 + const textToValidate = inputText.replace( + REGEX_PATTERNS.CUSTOM_DELIMITER_DEFINITION, + '', + ); + const allDelimiters = [...DEFAULT_DELIMITERS, ...escapedDelimiters]; + + allDelimiters.forEach((delimiter) => { + const escapedDelimiter = RegexUtils.escapeRegexChars(delimiter); + const continuousPattern = + RegexUtils.createContinuousPattern(escapedDelimiter); + + if (continuousPattern.test(textToValidate)) { + throw new Error(ERROR_MESSAGE.DELIMITER_CONTINUOUS); + } + }); + }, + + validateDelimiters(customDelimiters) { + customDelimiters.forEach((delimiter) => { + // 빈 값 체크 + if (!delimiter) { + throw new Error(ERROR_MESSAGE.DELIMITER_EMPTY); + } + // 공백 포함 체크 + if (REGEX_PATTERNS.WHITESPACE.test(delimiter)) { + throw new Error(ERROR_MESSAGE.DELIMITER_HAS_WHITESPACE); + } + // 숫자 포함 체크 + if (REGEX_PATTERNS.DIGIT.test(delimiter)) { + throw new Error(ERROR_MESSAGE.DELIMITER_HAS_NUMBER); + } + // 구분자가 한글자인지 체크 + if (delimiter.length !== 1) { + throw new Error(ERROR_MESSAGE.DELIMITER_NOT_SINGLE_CHAR); + } + }); + }, +}; + +export default validate; diff --git a/src/view/InputView.js b/src/view/InputView.js new file mode 100644 index 00000000..2b047c96 --- /dev/null +++ b/src/view/InputView.js @@ -0,0 +1,9 @@ +import { Console } from '@woowacourse/mission-utils'; + +const inputView = { + async readLineMessage(message) { + const input = await Console.readLineAsync(message); + return input; + }, +}; +export default inputView; diff --git a/src/view/OutputView.js b/src/view/OutputView.js new file mode 100644 index 00000000..3a6e9ab4 --- /dev/null +++ b/src/view/OutputView.js @@ -0,0 +1,8 @@ +import { Console } from '@woowacourse/mission-utils'; + +const outputView = { + async printMessage(message) { + await Console.print(message); + }, +}; +export default outputView; diff --git a/test/Contoller.test.js b/test/Contoller.test.js new file mode 100644 index 00000000..26e621a5 --- /dev/null +++ b/test/Contoller.test.js @@ -0,0 +1,66 @@ +import { Console } from '@woowacourse/mission-utils'; +import Controller from '../src/controller/Controller.js'; +import inputView from '../src/view/InputView.js'; + +jest.mock('../src/view/InputView.js', () => ({ + __esModule: true, + default: { + readLineMessage: jest.fn(), + }, +})); + +// __esModule을 추가해야지 Babel 트랜스파일된 코드에서 ESM의 default를 붙여준 거라, mock 구조가 ESM 형식에 맞게 설정 + +// Received: [Function mockConstructor] +// -> logSpy는 문자열이 아니라 jest mock 함수이므로 toHaveBeenCalledWith를 +describe('Controller 클래스 E2E 테스트', () => { + it('입력 : 1,2:3 | 출력 : 결과 : 6', async () => { + inputView.readLineMessage.mockImplementationOnce(() => '1,2:3'); + const controller = new Controller(); + const logSpy = jest.spyOn(Console, 'print').mockImplementation(() => {}); + await controller.run(); + expect(logSpy).toHaveBeenCalledWith('결과 : 6'); + }); + + it('음수 입력시 ERROR 반환', async () => { + inputView.readLineMessage.mockImplementationOnce(() => '-1,2:3'); + const controller = new Controller(); + await expect(controller.run()).rejects.toThrow('[ERROR]'); + }); + + it('입력이 한개인 경우 -1,', async () => { + inputView.readLineMessage.mockImplementationOnce(() => '1,'); + const controller = new Controller(); + const logSpy = jest.spyOn(Console, 'print').mockImplementation(() => {}); + await controller.run(); + expect(logSpy).toHaveBeenCalledWith('결과 : 1'); + }); + + it('//;\\n1 커스텀 구분자 사용', async () => { + inputView.readLineMessage.mockImplementationOnce(() => '//;\\n1'); + const controller = new Controller(); + const logSpy = jest.spyOn(Console, 'print').mockImplementation(() => {}); + await controller.run(); + expect(logSpy).toHaveBeenCalledWith('결과 : 1'); + }); + + it('입력 : 1.2,2.3:3.4 | 출력 : 결과 : 6.9', async () => { + inputView.readLineMessage.mockImplementationOnce(() => '1.2,2.3:3.4'); + const controller = new Controller(); + const logSpy = jest.spyOn(Console, 'print').mockImplementation(() => {}); + await controller.run(); + expect(logSpy).toHaveBeenCalledWith('결과 : 6.9'); + }); + + it('//;\\n1;; 연속된 구분자 사용', async () => { + inputView.readLineMessage.mockImplementationOnce(() => '//;\\n1;;'); + const controller = new Controller(); + await expect(controller.run()).rejects.toThrow('[ERROR]'); + }); + + it('1;1 미 언급 구분자 사용', async () => { + inputView.readLineMessage.mockImplementationOnce(() => '1;1'); + const controller = new Controller(); + await expect(controller.run()).rejects.toThrow('[ERROR]'); + }); +}); diff --git a/test/Extraction.test.js b/test/Extraction.test.js new file mode 100644 index 00000000..e4ab1731 --- /dev/null +++ b/test/Extraction.test.js @@ -0,0 +1,47 @@ +import Extraction from '../src/service/model/Extraction.js'; + +describe('Extraction 클래스 테스트', () => { + const extraction = new Extraction(); + + it('//ㅌ\\n 사이의 커스텀 구분자 ㅌ을 추출한다', () => { + const text = 'e//ㅌ\\neasd123'; + const result = extraction.extractCustomDelimiters(text); + expect(result.raw).toEqual(['ㅌ']); + expect(result.escaped).toEqual(['ㅌ']); + }); + + it('커스텀 구분자가 없다면 빈 배열로 반환된다', () => { + const text = 'eneasd123'; + const result = extraction.extractCustomDelimiters(text); + expect(result.raw).toEqual([]); + expect(result.escaped).toEqual([]); + }); + + it('커스텀 구분자 한개를 테스트하다 //;\\n1', () => { + const text = '//;\\n1'; + const result = extraction.extractCustomDelimiters(text); + expect(result.raw).toEqual([';']); + expect(result.escaped).toEqual([';']); + }); + + it('커스텀 구분자 두개를 테스트하다 //;\\n1,//ㅁ\\n1', () => { + const text = '//;\\n1,//ㅁ\\n1'; + const result = extraction.extractCustomDelimiters(text); + expect(result.raw).toEqual([';', 'ㅁ']); + expect(result.escaped).toEqual([';', 'ㅁ']); + }); + + it('커스텀 구분자가 특수문자인 경우 이스케이프 처리된다', () => { + const text = '//.\\n1'; + const result = extraction.extractCustomDelimiters(text); + expect(result.raw).toEqual(['.']); + expect(result.escaped).toEqual(['\\.']); + }); + + it('여러 특수문자를 포함한 경우 이스케이프 처리된다', () => { + const text = '//*\\n1//+\\n2'; + const result = extraction.extractCustomDelimiters(text); + expect(result.raw).toEqual(['*', '+']); + expect(result.escaped).toEqual(['\\*', '\\+']); + }); +}); diff --git a/test/Number.test.js b/test/Number.test.js new file mode 100644 index 00000000..a91a595c --- /dev/null +++ b/test/Number.test.js @@ -0,0 +1,8 @@ +import Number from '../src/service/model/Number.js'; + +describe('넘버 클래스를 테스트 하다', () => { + it('숫자를 더한다.', () => { + const numbers = new Number([1, 2, 3]); + expect(numbers.getAddedNumbers()).toBe(6); + }); +}); diff --git a/test/Parser.test.js b/test/Parser.test.js new file mode 100644 index 00000000..1f796ded --- /dev/null +++ b/test/Parser.test.js @@ -0,0 +1,42 @@ +import Parser from '../src/service/model/Parser.js'; + +describe('Parser 클래스를 테스트 하다', () => { + const parser = new Parser(); + it('구분자와 입력을 받아 파싱을 하다', () => { + const escapedDelimiters = ['\\/', '\\|']; + const text = '2/123/2|31'; + expect(parser.parseToNumbers(text, escapedDelimiters)).toEqual([ + 2, 123, 2, 31, + ]); + }); +}); + +describe('Parser 클래스의 useCase를 추가하다', () => { + const parser = new Parser(); + + it('구분자는 기본적으로 , : 를 포함합니다', () => { + const escapedDelimiters = []; + const text = '11:6,1'; + expect(parser.parseToNumbers(text, escapedDelimiters)).toEqual([11, 6, 1]); + }); + + it('구분자가 여러글자 인경우', () => { + const escapedDelimiters = ['ab', 'bc', 'cd']; + const text = '1ab2bc2cd3'; + expect(parser.parseToNumbers(text, escapedDelimiters)).toEqual([ + 1, 2, 2, 3, + ]); + }); +}); + +describe('파싱 - 커스텀 구분자 정의 제거', () => { + const parser = new Parser(); + + it('커스텀 구분자 정의가 여러개 있는 경우', () => { + const delimiters = [';', '|']; + const text = '//;\\n//|\\n1'; + expect(parser.removeCustomDelimiterDefinitions(text, delimiters)).toEqual( + '1', + ); + }); +}); diff --git a/test/RandomMaker.test.js b/test/RandomMaker.test.js new file mode 100644 index 00000000..3ad9ee57 --- /dev/null +++ b/test/RandomMaker.test.js @@ -0,0 +1,79 @@ +// 랜덤 문자열 생성기 + +import { Console } from '@woowacourse/mission-utils'; + +import Controller from '../src/controller/Controller.js'; +import inputView from '../src/view/InputView.js'; +import Parser from '../src/service/model/Parser.js'; +import Extraction from '../src/service/model/Extraction.js'; +import { compareArrays, getRandomInput, saveErrorCase } from './randomUtil.js'; + +jest.mock('../src/view/InputView.js', () => ({ + __esModule: true, + default: { + readLineMessage: jest.fn(), + }, +})); + +const TOTAL_ITERATIONS = 10000; + +describe(`랜덤 테스트 ${TOTAL_ITERATIONS}`, () => { + it(`커스텀 구분자를 통해 ${TOTAL_ITERATIONS} 테스트 합니다`, async () => { + for (let i = 1; i <= TOTAL_ITERATIONS; i++) { + const [input, output, parseMumber, extracionRegex] = getRandomInput(); + + inputView.readLineMessage.mockImplementationOnce(() => input); + + const controller = new Controller(); + const logSpy = jest.spyOn(Console, 'print').mockImplementation(() => {}); + const parsedValueSpy = jest.spyOn(Parser.prototype, 'parseToNumbers'); + const extractionValueSpy = jest.spyOn( + Extraction.prototype, + 'extractCustomDelimiters', + ); + + try { + await controller.run(); + + const extractionSpyValue = extractionValueSpy.mock.results[0].value; + const parsedSpyValue = parsedValueSpy.mock.results[0].value; + + if (i % 100 === 0) { + console.log(`진행 중: ${i}/${TOTAL_ITERATIONS} `); + } + + compareArrays(parseMumber, parsedSpyValue); + + expect(new Set(extracionRegex)).toEqual( + new Set(extractionSpyValue.raw), + ); + expect(parseMumber).toEqual(parsedSpyValue); + expect(logSpy).toHaveBeenCalledWith(`결과 : ${output}`); + } catch (err) { + // 에러 발생 시 즉시 저장하고 테스트 중단 + saveErrorCase( + input, + output, + parseMumber, + extracionRegex, + i, + err.message, + ); + + // 에러를 다시 던져서 테스트 실패 처리 + throw new Error( + `테스트 실패 (${i}/${TOTAL_ITERATIONS}번째)\n원본 에러: ${err.message}`, + ); + } + + // spy 정리 + logSpy.mockRestore(); + parsedValueSpy.mockRestore(); + extractionValueSpy.mockRestore(); + } + + console.log( + `\n모든 테스트 통과! (${TOTAL_ITERATIONS}/${TOTAL_ITERATIONS})`, + ); + }); +}); diff --git a/test/randomUtil.js b/test/randomUtil.js new file mode 100644 index 00000000..0c8c88f9 --- /dev/null +++ b/test/randomUtil.js @@ -0,0 +1,100 @@ +import fs from 'fs'; +import path from 'path'; +import { Random } from '@woowacourse/mission-utils'; + +const ERROR_DIR = path.join(process.cwd(), 'error_cases'); +if (!fs.existsSync(ERROR_DIR)) fs.mkdirSync(ERROR_DIR); +const errorFilePath = path.join(ERROR_DIR, 'last_error.json'); + +const getCustomRegexWithText = (text) => `//${text}\\n`; +const getRandomNumber = (start = 1, end = 9) => + Random.pickNumberInRange(start, end); + +// 커스텀 구분자 생성기 +const makeCustomRegex = (customRegexCount) => { + const customRegexs = []; + for (let i = 0; i < customRegexCount; i++) { + let charCode; + do { + charCode = Random.pickNumberInRange(33, 126); // 32번(공백) 제외 + } while (charCode >= 48 && charCode <= 57); // 숫자 제외 + customRegexs.push(String.fromCharCode(charCode)); + } + return [...new Set(customRegexs)]; +}; + +// ARRAY -> 랜덤한 value 추출 +const getRandomValueInArray = (values) => { + const index = getRandomNumber(0, values.length - 1); + return values[index]; +}; + +// 애러 저장 +export const saveErrorCase = (input, output, parseMumber, extracionRegex) => { + fs.writeFileSync( + errorFilePath, + JSON.stringify({ input, output, extracionRegex, parseMumber }, null, 4), + ); +}; + +// 랜덤 인풋 생성기 +export const getRandomInput = () => { + // 애러가 나면 애러부터 다시 검증 + if (fs.existsSync(errorFilePath)) { + const data = JSON.parse(fs.readFileSync(errorFilePath, 'utf-8')); + return [data.input, data.output, data.parseMumber, data.extracionRegex]; + } + + let definitionString = ''; // "//P\n//l\n" 등 정의가 담길 부분 + let numberString = ''; // "123P456l789..." 숫자가 담길 부분 + let outputResult = 0; + + const customRegexCount = getRandomNumber(1, 100); + const madeCustomRegex = makeCustomRegex(customRegexCount); + const parseMumber = []; + + const usedCustomRegex = []; + const customRegexNumToUse = getRandomNumber(0, madeCustomRegex.length); + while (usedCustomRegex.length < customRegexNumToUse) { + const regex = getRandomValueInArray(madeCustomRegex); + if (!usedCustomRegex.includes(regex)) { + usedCustomRegex.push(regex); + } + } + + usedCustomRegex.forEach((regex) => { + definitionString += getCustomRegexWithText(regex); // `//${regex}\n` + }); + + const allAvailableDelimiters = [...usedCustomRegex, ':', ',']; + + const numberCount = getRandomNumber(1, 1000); + + for (let i = 0; i < numberCount; i++) { + const randNumber = getRandomNumber(0, 100000); + numberString += randNumber; // 숫자 문자열에 추가 + outputResult += randNumber; + parseMumber.push(randNumber); + + if (i < numberCount - 1) { + const delimiter = getRandomValueInArray(allAvailableDelimiters); + numberString += delimiter; // //P\n가 아닌 P 자체가 추가됨 + } + } + + const inputResult = definitionString + numberString; + + return [inputResult, outputResult, parseMumber, usedCustomRegex]; +}; + +export const compareArrays = (expected, actual) => { + const maxLength = Math.max(expected.length, actual.length); + + for (let i = 0; i < maxLength; i++) { + const exp = expected[i]; + const act = actual[i]; + if (exp !== act) { + throw new Error(`인덱스 ${i}: expected=${exp}, actual=${act}`); + } + } +}; diff --git a/test/validate.test.js b/test/validate.test.js new file mode 100644 index 00000000..c67add88 --- /dev/null +++ b/test/validate.test.js @@ -0,0 +1,205 @@ +// 잘못된 입력에 대한 검증 +import validate from '../src/service/validate/validate.js'; +import { ERROR_MESSAGE } from '../src/constant/error.js'; + +describe('validate.validateNumbers - 숫자 검증', () => { + describe('정상 케이스', () => { + it('정상적인 숫자 배열은 에러를 발생시키지 않는다', () => { + const numbers = [1, 2, 3]; + expect(() => validate.validateNumbers(numbers)).not.toThrow(); + }); + + it('0을 포함한 숫자 배열은 정상 처리된다', () => { + const numbers = [0, 1, 2]; + expect(() => validate.validateNumbers(numbers)).not.toThrow(); + }); + + it('문자열 형태의 숫자는 정상 처리된다', () => { + const numbers = ['1', '2', '3']; + expect(() => validate.validateNumbers(numbers)).not.toThrow(); + }); + }); + + describe('예외 케이스 - 숫자가 아닌 값', () => { + it('숫자가 아닌 문자가 포함된 경우 에러를 발생시킨다', () => { + const numbers = ['우', '테', '코', 1]; + expect(() => validate.validateNumbers(numbers)).toThrow( + ERROR_MESSAGE.NOT_NUMBER, + ); + }); + + it('알파벳이 포함된 경우 에러를 발생시킨다', () => { + const numbers = ['a', 'b', 1]; + expect(() => validate.validateNumbers(numbers)).toThrow( + ERROR_MESSAGE.NOT_NUMBER, + ); + }); + }); + + describe('예외 케이스 - 공백/빈값', () => { + it('빈 문자열이 포함된 경우 에러를 발생시킨다', () => { + const numbers = ['', 1, 2, 1]; + expect(() => validate.validateNumbers(numbers)).toThrow( + ERROR_MESSAGE.NOT_EMPTY, + ); + }); + + it('공백 문자열이 포함된 경우 에러를 발생시킨다', () => { + const numbers = [' ', 1, 2]; + expect(() => validate.validateNumbers(numbers)).toThrow( + ERROR_MESSAGE.NOT_EMPTY, + ); + }); + + it('여러 개의 공백이 포함된 경우 에러를 발생시킨다', () => { + const numbers = [' ', 1, 2]; + expect(() => validate.validateNumbers(numbers)).toThrow( + ERROR_MESSAGE.NOT_EMPTY, + ); + }); + }); + + describe('예외 케이스 - 음수', () => { + it('음수가 포함된 경우 에러를 발생시킨다', () => { + const numbers = [-1, 2, 3]; + expect(() => validate.validateNumbers(numbers)).toThrow( + ERROR_MESSAGE.NOT_MINUS, + ); + }); + + it('0보다 작은 음수가 포함된 경우 에러를 발생시킨다', () => { + const numbers = [1, -5, 3]; + expect(() => validate.validateNumbers(numbers)).toThrow( + ERROR_MESSAGE.NOT_MINUS, + ); + }); + }); +}); + +describe('validate.validateContinuousDelimiters - 연속된 구분자 검증', () => { + describe('정상 케이스', () => { + it('구분자가 연속되지 않은 경우 에러를 발생시키지 않는다', () => { + const inputText = '1,2:3'; + const escapedDelimiters = []; + expect(() => + validate.validateContinuousDelimiters(inputText, escapedDelimiters), + ).not.toThrow(); + }); + + it('커스텀 구분자가 연속되지 않은 경우 에러를 발생시키지 않는다', () => { + const inputText = '1;2;3'; + const escapedDelimiters = [';']; + expect(() => + validate.validateContinuousDelimiters(inputText, escapedDelimiters), + ).not.toThrow(); + }); + }); + + describe('예외 케이스 - 연속된 구분자', () => { + it('쉼표가 연속된 경우 에러를 발생시킨다', () => { + const inputText = '1,,2'; + const escapedDelimiters = []; + expect(() => + validate.validateContinuousDelimiters(inputText, escapedDelimiters), + ).toThrow(ERROR_MESSAGE.DELIMITER_CONTINUOUS); + }); + + it('콜론이 연속된 경우 에러를 발생시킨다', () => { + const inputText = '1::2'; + const escapedDelimiters = []; + expect(() => + validate.validateContinuousDelimiters(inputText, escapedDelimiters), + ).toThrow(ERROR_MESSAGE.DELIMITER_CONTINUOUS); + }); + + it('커스텀 구분자가 연속된 경우 에러를 발생시킨다', () => { + const inputText = '1;;2'; + const escapedDelimiters = [';']; + expect(() => + validate.validateContinuousDelimiters(inputText, escapedDelimiters), + ).toThrow(ERROR_MESSAGE.DELIMITER_CONTINUOUS); + }); + + it('세 개 이상 연속된 구분자도 에러를 발생시킨다', () => { + const inputText = '1,,,2'; + const escapedDelimiters = []; + expect(() => + validate.validateContinuousDelimiters(inputText, escapedDelimiters), + ).toThrow(ERROR_MESSAGE.DELIMITER_CONTINUOUS); + }); + }); +}); + +describe('validate.validateDelimiters - 구분자 유효성 검증', () => { + describe('정상 케이스', () => { + it('올바른 커스텀 구분자는 에러를 발생시키지 않는다', () => { + const customDelimiters = [';', '|']; + expect(() => validate.validateDelimiters(customDelimiters)).not.toThrow(); + }); + + it('특수문자 구분자는 정상 처리된다', () => { + const customDelimiters = ['!', '@', '#']; + expect(() => validate.validateDelimiters(customDelimiters)).not.toThrow(); + }); + + it('한글 구분자는 정상 처리된다', () => { + const customDelimiters = ['가', 'ㄱ']; + expect(() => validate.validateDelimiters(customDelimiters)).not.toThrow(); + }); + }); + + describe('예외 케이스 - 빈 구분자', () => { + it('빈 문자열 구분자는 에러를 발생시킨다', () => { + const customDelimiters = ['']; + expect(() => validate.validateDelimiters(customDelimiters)).toThrow( + ERROR_MESSAGE.DELIMITER_EMPTY, + ); + }); + + it('여러 구분자 중 빈 값이 포함된 경우 에러를 발생시킨다', () => { + const customDelimiters = [';', '', '|']; + expect(() => validate.validateDelimiters(customDelimiters)).toThrow( + ERROR_MESSAGE.DELIMITER_EMPTY, + ); + }); + }); + + describe('예외 케이스 - 공백 포함', () => { + it('공백이 포함된 구분자는 에러를 발생시킨다', () => { + const customDelimiters = [' ']; + expect(() => validate.validateDelimiters(customDelimiters)).toThrow( + ERROR_MESSAGE.DELIMITER_HAS_WHITESPACE, + ); + }); + + it('구분자에 공백이 섞여있는 경우 에러를 발생시킨다', () => { + const customDelimiters = ['a b']; + expect(() => validate.validateDelimiters(customDelimiters)).toThrow( + ERROR_MESSAGE.DELIMITER_HAS_WHITESPACE, + ); + }); + }); + + describe('예외 케이스 - 숫자 포함', () => { + it('숫자가 포함된 구분자는 에러를 발생시킨다', () => { + const customDelimiters = ['1']; + expect(() => validate.validateDelimiters(customDelimiters)).toThrow( + ERROR_MESSAGE.DELIMITER_HAS_NUMBER, + ); + }); + + it('문자와 숫자가 섞인 구분자는 에러를 발생시킨다', () => { + const customDelimiters = ['a1']; + expect(() => validate.validateDelimiters(customDelimiters)).toThrow( + ERROR_MESSAGE.DELIMITER_HAS_NUMBER, + ); + }); + + it('여러 구분자 중 숫자가 포함된 경우 에러를 발생시킨다', () => { + const customDelimiters = [';', '5', '|']; + expect(() => validate.validateDelimiters(customDelimiters)).toThrow( + ERROR_MESSAGE.DELIMITER_HAS_NUMBER, + ); + }); + }); +});