diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..62f705b --- /dev/null +++ b/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["plugin:prettier/recommended"], +} diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..6923972 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "endOfLine": "lf", + "tabWidth": 2, + "printWidth": 80, + "singleQuote": true, + "bracketSameLine": false, + "bracketSpacing": true, + "arrowParens": "always", + "singleAttributePerLine": true +} diff --git a/eslint.config.js b/eslint.config.js index 1aa8477..a027e87 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,8 +1,9 @@ -import globals from "globals"; -import pluginJs from "@eslint/js"; - +import globals from 'globals'; +import pluginJs from '@eslint/js'; +import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; export default [ - {languageOptions: { globals: {...globals.browser, ...globals.node} }}, + { languageOptions: { globals: { ...globals.browser, ...globals.node } } }, pluginJs.configs.recommended, -]; \ No newline at end of file + eslintPluginPrettierRecommended, +]; diff --git a/package-lock.json b/package-lock.json index 21b2a01..e16d6a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,10 @@ "devDependencies": { "@eslint/js": "^9.5.0", "eslint": "^9.5.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", "globals": "^15.6.0", + "prettier": "3.3.2", "vitest": "^1.6.0" } }, @@ -565,6 +568,18 @@ "node": ">= 8" } }, + "node_modules/@pkgr/core": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.1.1.tgz", + "integrity": "sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA==", + "dev": true, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", @@ -1223,6 +1238,48 @@ "url": "https://eslint.org/donate" } }, + "node_modules/eslint-config-prettier": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", + "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", + "dev": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", + "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", + "dev": true, + "dependencies": { + "prettier-linter-helpers": "^1.0.0", + "synckit": "^0.8.6" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": "*", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, "node_modules/eslint-scope": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.0.1.tgz", @@ -1348,6 +1405,12 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "dev": true }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -1975,6 +2038,33 @@ "node": ">= 0.8.0" } }, + "node_modules/prettier": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", + "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz", + "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==", + "dev": true, + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -2221,6 +2311,22 @@ "node": ">=8" } }, + "node_modules/synckit": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.8.8.tgz", + "integrity": "sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ==", + "dev": true, + "dependencies": { + "@pkgr/core": "^0.1.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -2251,6 +2357,12 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "dev": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index e0cf22f..f5b6623 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,10 @@ "devDependencies": { "@eslint/js": "^9.5.0", "eslint": "^9.5.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", "globals": "^15.6.0", + "prettier": "3.3.2", "vitest": "^1.6.0" } } diff --git a/src/__tests__/car.test.js b/src/__tests__/car.test.js deleted file mode 100644 index 3f2ef1e..0000000 --- a/src/__tests__/car.test.js +++ /dev/null @@ -1,71 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import Car from '../model/Car.js'; - -describe('car 클래스에 대한 단위 테스트.', () => { - const car = new Car('meca'); - const position = 4; - - test('setName 에 올바른 문자열을 할당하면 해당 문자열을 반환한다.', () => { - const nextName = car.setName('Dva'); - - expect(nextName).toBe('Dva'); - }), - test.each([ - { - name: ['hello'], - }, - { - name: '', - }, - { - name: { name: 'hello' }, - }, - { name: undefined }, - ])( - 'setName 에 문자열이 아닌 값($name)을 할당하면 에러를 반환한다.', - ({ name }) => { - expect(() => { - car.setName(name); - }).toThrow(); - } - ), - test('getName 은 현재 car 인스턴스의 이름을 반환한다.', () => { - const currName = car.getName(); - - expect(currName).toBe('Dva'); - }), - test('setPosition 에 올바른 숫자를 할당하면 해당 숫자를 반환한다.', () => { - const nextPosition = car.setPosition(position); - - expect(nextPosition).toBe(position); - }), - test.each([ - { position: '1' }, - { position: [] }, - { position: {} }, - { position: undefined }, - ])( - 'setPosition 에 숫자가 아닌 값($position)을 할당하면 에러를 반환한다.', - ({ position }) => { - expect(() => { - car.setPosition(position); - }).toThrow(); - } - ), - test('getPosition 은 현재 포지션의 값을 반환해야 한다.', () => { - expect(car.getPosition()).toBe(position); - }), - test.each([ - { distance: 1 }, - { distance: 2 }, - { distance: 3 }, - { distance: 4 }, - ])( - 'move 는 현재 포지션에 ($distance) 값을 추가한 만큼 position 을 증가시켜야 한다.', - ({ distance }) => { - const prevPosition = car.getPosition(); - - expect(car.move(distance)).toBe(prevPosition + distance); - } - ); -}); diff --git a/src/__tests__/main.test.js b/src/__tests__/main.test.js deleted file mode 100644 index cf18ebf..0000000 --- a/src/__tests__/main.test.js +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import Main from '../main.js'; - -describe('Main 클래스에 대한 단위 테스트 진행', () => { - test.each([ - { rounds: '' }, - { rounds: function () {} }, - { rounds: null }, - { rounds: ['12', '23'] }, - ])( - 'Main 클래스 초기화 시 전달하는 ($rounds) 는 문자열 배열이 아니면 에러를 반환한다.', - ({ rounds }) => { - expect(() => { - new Main(rounds); - }).toThrow(); - } - ), - test('setRounds 에 올바른 값을 넣으면 해당 값을 다시 반환한다.', () => { - const rounds = [3, 4, 5]; - const main = new Main([5]); - const nextRounds = main.setRounds(rounds); - - expect(nextRounds.every((value, index) => value === rounds[index])).toBe( - true - ); - }); -}); diff --git a/src/__tests__/race.test.js b/src/__tests__/race.test.js new file mode 100644 index 0000000..1ea4691 --- /dev/null +++ b/src/__tests__/race.test.js @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'vitest'; +import Race from '../model/Race'; +import Car from '../model/Car'; +import gameSupport from '../utils/gameSupport'; +import Rule from '../model/Rule'; +import Animal from '../model/Animal'; + +describe('Race 클래스에 대한 단위 테스트 작성', () => { + test('Race 를 초기화 할때 Racer 의 서브클래스가 아닌 클래스를 Racer 프로퍼티에 전달하면 에러를 발생한다', () => { + expect(() => { + makeRaceMockData({ Racer: Rule }); + }).toThrow(); + }); + + test.each([ + { round: '으아' }, + { round: {} }, + { round: null }, + { round: undefined }, + { round: function round() {} }, + ])( + 'Race 를 초기화할 때 round 에 숫자 형태로 바뀔 수 잇는 값($round)이 아닌 값을 전달하면 에러를 발생한다.', + ({ round }) => { + expect(() => { + makeRaceMockData({ round }); + }).toThrow(); + }, + ); + + test.each([{ round: 0 }, { round: 11 }])( + 'Race 를 초기화할 때 round 에 1미만 10초과의 숫자($round)를 전달하면 에러가 난다.', + ({ round }) => { + expect(() => { + makeRaceMockData({ round }); + }).toThrow(); + }, + ); + + test.each([ + { names: undefined }, + { names: null }, + { names: {} }, + { names: function name() {} }, + { names: 123 }, + { names: [123, 124] }, + ])( + 'Race 초기화 시 names 에 문자열이 아닌 값($names)이 들어가면 에러가 발생한다.', + ({ names }) => { + expect(() => { + makeRaceMockData({ names }); + }).toThrow(); + }, + ); + + test.each([{ names: '' }, { names: '1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11' }])( + 'Race 초기화 시 names($names)을 콤마로 나눠 1명 미만 10명 초과의 레이서를 가지게 되면 에러가 발생한다.', + ({ names }) => { + expect(() => { + makeRaceMockData({ names }); + }).toThrow(); + }, + ); + + test.each([ + { rules: null }, + { rules: '' }, + { rules: { diceRule: () => 9 } }, + { rules: [] }, + { rules: function diceRule() {} }, + ])( + 'Race 초기화 시 rules 프로퍼티에 불리언값을 반환하는 함수를 값으로 갖는 객체($rules)로 이루어져있지 않다면 에러를 발생한다.', + ({ rules }) => { + expect(() => { + makeRaceMockData({ rules }); + }).toThrow(); + }, + ); + + test('round 프로퍼티를 통해 round 를 가져올 수 있다.', () => { + const race = makeRaceMockData({ round: 10 }); + + expect(race.round).toBe(10); + }); + + test('round 프로퍼티를 통해 round 를 설정할 수 있다.', () => { + const race = makeRaceMockData({ round: 10 }); + race.round = 6; + + expect(race.round).toBe(6); + }); + + test('Racer 프로퍼티를 통해 현재 Racer 에 해당하는 클래스를 가져올 수 있다.', () => { + const race = makeRaceMockData({ Racer: Car }); + + expect(race.Racer).toBe(Car); + }); + + test('Racer 프로퍼티를 통해 현재 Racer 에 해당하는 클래스를 가져올 수 있다.', () => { + const race = makeRaceMockData({ Racer: Car }); + race.Racer = Animal; + + expect(race.Racer).toBe(Animal); + }); + + test('getRacerState 메서드는 현재 레이서들의 이름과 포지션을 받아올 수 있다.', () => { + const names = ['a', 'b']; + const race = makeRaceMockData({ names: 'a, b' }); + const state = race.getRacerStatus(); + + expect( + state.every( + ({ name, position }) => names.includes(name) && position === 0, + ), + ).toBeTruthy(); + }); + + test('startRace 메서드는 레이스를 진행하고 우승자 이름 목록을 반환한다.', () => { + const race = makeRaceMockData({ names: 'a', rules: { win: () => true } }); + + expect(race.startRace()[0]).toBe('a'); + }); +}); + +function makeRaceMockData(raceProperty) { + const race = new Race({ + Racer: Car, + round: 5, + rules: { + diceRule: () => gameSupport.dice(0, 10) >= 4, + }, + names: '1, 2, 3', + ...raceProperty, + }); + + return race; +} diff --git a/src/__tests__/racer.test.js b/src/__tests__/racer.test.js new file mode 100644 index 0000000..a982835 --- /dev/null +++ b/src/__tests__/racer.test.js @@ -0,0 +1,113 @@ +import { describe, expect, test } from 'vitest'; +import Racer from '../model/Racer'; + +describe('Racer 클래스에 대한 단위 테스트', () => { + test('Racer 를 초기화 할 때 name 프로퍼티에 앞 뒤 공백을 제외한 1~5자 사이의 문자열을 입력하면 아무 에러를 반환하지 않는다.', () => { + expect(() => { + makeRacerMockData({ name: '수연' }); + }).not.toThrow(); + }); + + test('Racer 를 초기화할 때 position 프로퍼티에 숫자 타입을 입력하면 아무 에러를 반환하지 않는다.', () => { + expect(() => { + makeRacerMockData({ name: '수연', position: 0 }); + }).not.toThrow(); + }); + + test.each([ + { name: undefined }, + { name: 1 }, + { name: null }, + { name: function name() {} }, + { name: {} }, + ])( + 'name 프로퍼티에 할당하는 값($name) 문자열 타입이 아니면 에러를 발생한다.', + ({ name }) => { + expect(() => { + makeRacerMockData({ name }, false); + }).toThrow(); + }, + ); + + test.each([{ name: '' }, { name: '123456' }])( + 'name 프로퍼티에 해당하는 값($name)이 1글자 미만 5글자 초과인 경우 에러를 발생한다', + ({ name }) => { + expect(() => { + makeRacerMockData({ name }, false); + }).toThrow(); + }, + ); + + test.each([ + { position: undefined }, + { position: '1' }, + { position: null }, + { position: function num() {} }, + { position: {} }, + ])( + 'position 프로퍼티에 해당하는 값($position)이 숫자 타입이 아닌 경우 에러를 발생한다.', + ({ position }) => { + expect(() => { + makeRacerMockData({ position }, false); + }).toThrow(); + }, + ); + + test('position 프로퍼티를 가져올 수 있다.', () => { + const racer = makeRacerMockData({ position: 5 }); + + expect(racer.position).toBe(5); + }); + + test('position 프로퍼티를 가져올 수 있다.', () => { + const racer = makeRacerMockData({ position: 5 }); + + expect(racer.position).toBe(5); + }); + + test('init 메서드를 사용하면 postion 이 0 이 된다.', () => { + const racer = makeRacerMockData({ position: 5 }); + racer.init(); + + expect(racer.position).toBe(0); + }); + + test('init 함수를 사용하면 postion 이 0 이 된다.', () => { + const racer = makeRacerMockData({ position: 5 }); + racer.init(); + + expect(racer.position).toBe(0); + }); + + test('move 메서드에 숫자 타입을 전달하면 변경된 position 을 반환한다.', () => { + const racer = makeRacerMockData({ position: 0 }); + const nextPosition = racer.move(5); + + expect(nextPosition).toBe(5); + }); + + test.each([ + { distance: '1' }, + { distance: function num() {} }, + { distance: {} }, + ])( + 'move 메서드에 빈값 (undefined, null)이나 숫자가 아닌 값($distance)을 전달하면 에러를 발생한다.', + ({ distance }) => { + const racer = makeRacerMockData(); + + expect(() => { + racer.move(distance); + }).toThrow(); + }, + ); +}); + +function makeRacerMockData(raceProperty) { + const racer = new Racer({ + name: '수연', + position: 0, + ...raceProperty, + }); + + return racer; +} diff --git a/src/__tests__/racing.test.js b/src/__tests__/racing.test.js deleted file mode 100644 index 00f6fc0..0000000 --- a/src/__tests__/racing.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import Racing from '../model/Racing.js'; -import Car from '../model/Car'; - -describe('Racing 클래스에 대한 단위 테스트', () => { - const carSet = new Set(['jjanggu', 'hodu']); - const racing = new Racing(5, ['jjanggu', 'hodu']); - - test('setRound 에 올바른 값을 할당하면 round 가 해당 값으로 바뀐다.', () => { - expect(racing.getRound()).toBe(5); - }), - test('setCars 에 올바른 값을 할당하면 Car 인스턴스가 담긴다.', () => { - const isCarArray = racing.getCars().every((car) => car instanceof Car); - - expect(isCarArray).toBe(true); - }), - test('startRacing 메서드는 자동차 이름을 포함한 문자열 배열을 반환한다.', () => { - const winners = racing.startRacing(); - const isRightWinner = winners.every((winner) => carSet.has(winner)); - - expect(isRightWinner).toBe(true); - }), - test('dice 메서드는 0~9 사이의 값을 반환한다.', () => { - const diceResult = [...new Array(10)].fill(Racing.dice()); - const isCollectNum = diceResult.every((dice) => dice <= 9 && dice >= 0); - - expect(isCollectNum).toBe(true); - }), - test('getWinners 는 자동차 이름을 포함한 문자열 배열을 반환한다.', () => { - const winners = racing.getWinners(); - const isRightWinner = winners.every((winner) => carSet.has(winner)); - - expect(isRightWinner).toBe(true); - }); -}); diff --git a/src/__tests__/rule.test.js b/src/__tests__/rule.test.js new file mode 100644 index 0000000..aa761fc --- /dev/null +++ b/src/__tests__/rule.test.js @@ -0,0 +1,164 @@ +import { describe, expect, test } from 'vitest'; +import Rule from '../model/Rule'; +import gameSupport from '../utils/gameSupport'; + +describe('Rule 클래스에 대한 단위 테스트', () => { + const truthyRules = { + rule1: () => { + return true; + }, + rule2: () => { + return true; + }, + }; + const halfTruthyRules = { + rule1: () => { + return true; + }, + rule2: () => { + return false; + }, + }; + test('Rule 의 생성자 매개변수론 boolean 값을 반환하는 함수를 값으로 가지는 객체를 전달해야 한다.', () => { + expect(() => { + createCorrectRule(); + }).not.toThrow(); + }); + + test('Rule 의 생성자 생성자 매개변수로 boolean 값을 반환하는 함수 값을 가진 객체 외의 값을 전달하는 경우 에러를 반환한다.', () => { + expect(() => { + createUncorrectRule(); + }).toThrow(); + }); + + test('getKeys 메서드는 현재 룰의 모든 키값을 반환한다.', () => { + const rule = new Rule({ ...truthyRules }); + const keys = Object.keys(truthyRules); + const isSameKey = rule.getKeys().every((key) => keys.includes(key)); + + expect(isSameKey).toBeTruthy(); + }); + + test('run 메서드는 모든 규칙이 true 를 반환하는 경우 true 를 반환한다.', () => { + const rule = new Rule({ ...truthyRules }); + + expect(rule.run()).toBeTruthy(); + }); + + test('run 메서드는 하나라도 규칙 실행의 결과가 true 가 아닐 경우 false 를 반환한다.', () => { + const rule = new Rule({ ...halfTruthyRules }); + + expect(rule.run()).toBeFalsy(); + }); + + test('change 메서드는 룰에 없는 키 값을 바꾸려고 하는 경우 에러를 반환한다.', () => { + const rule = new Rule({ ...halfTruthyRules }); + + expect(() => { + rule.change('rule3', () => { + return false; + }); + }).toThrow(); + }); + + test('change 메서드는 룰에 있는 키 값을 전달할 경우 전달한 value 값으로 객체 값을 바꾼다.', () => { + const rule = new Rule({ ...halfTruthyRules }); + rule.change('rule2', () => { + return true; + }); + + expect(rule.run()).toBe(true); + }); + + test('change 메서드에 boolean 값을 반환하는 함수가 아닌, 다른 값을 전달하면 에러를 반환한다.', () => { + const rule = new Rule({ ...halfTruthyRules }); + + expect(() => { + rule.change('rule2', () => { + return 3; + }); + }).toThrow(); + }); + + test('add 메서드에 이미 존재하는 키를 추가하려고 하면 에러를 반환한다.', () => { + const rule = new Rule({ ...truthyRules }); + + expect(() => { + rule.add('rule1', () => { + return true; + }); + }).toThrow(); + }); + + test('add 메서드로 룰 객체에 없는 키를 추가하면 해당 프로퍼티를 추가한다.', () => { + const rule = new Rule({ ...truthyRules }); + rule.add('rule3', () => { + return true; + }); + + expect(rule.run()).toBeTruthy(); + }); + + test('add 메서드에 불리언 값을 반환하는 함수가 아닌 다른 타입을 전달하면 에러를 반환한다.', () => { + const rule = new Rule({ ...truthyRules }); + + expect(() => { + rule.add('rule3', () => { + return undefined; + }); + }).toThrow(); + }); + + test('remove 메서드는 매개변수로 전달한 key 값을 없앤다.', () => { + const rule = new Rule({ ...truthyRules }); + rule.remove('rule1'); + + expect(rule.isExist('rule')).toBeFalsy(); + }); + + test('removeAll 메서드는 rules 프로퍼티를 모두 삭제한다.', () => { + const rule = new Rule({ ...truthyRules }); + const keys = rule.getKeys(); + rule.removeAll(); + + expect(keys.every((key) => rule.isExist(key) === false)).toBeTruthy(); + }); + + test('isExistKey 메서드에 이미 존재하는 키를 매개변수로 전달하면 true를 반환한다.', () => { + const rule = new Rule({ ...truthyRules }); + + expect(rule.isExist('rule1')).toBeTruthy(); + }); + + test('isExistKey 메서드에 존재하지 않는 키를 매개변수로 전달하면 false를 반환한다.', () => { + const rule = new Rule({ ...truthyRules }); + + expect(rule.isExist('rule3')).toBeFalsy(); + }); +}); + +function createCorrectRule() { + const rule = new Rule({ + dice: () => { + return gameSupport.dice(0, 10) >= 4; + }, + }); + + return rule; +} + +function createUncorrectRule() { + const uncorrectRules = [ + undefined, + null, + 1, + { + a: () => { + return 1; + }, + }, + ]; + const rules = uncorrectRules.map((rule) => new Rule(rule)); + + return rules; +} diff --git a/src/__tests__/utils.test.js b/src/__tests__/utils.test.js new file mode 100644 index 0000000..96654cf --- /dev/null +++ b/src/__tests__/utils.test.js @@ -0,0 +1,32 @@ +import { describe, expect, test } from 'vitest'; +import gameSupport from '../utils/gameSupport'; +import isSubClass from '../utils/isSubClass'; +import Racer from '../model/Racer'; +import Car from '../model/Car'; +import Race from '../model/Race'; + +describe('gameSupport 유틸 함수 테스트', () => { + test('다이스에 최대값과 최소값을 전달하면 최소값과 최대값 사이의 값을 반환해야 한다.', () => { + const min = 0; + const max = 10; + const results = [...new Array(10)].map(() => gameSupport.dice(min, max)); + + expect(results.every((result) => result >= min && result < max)).toBe(true); + }); +}); + +describe('isSubclass 유틸 함수 테스트', () => { + test('어떤 클래스를 상속받은 클래스와 원본 클래스의 경우 true 를 반환한다.', () => { + const subClass = Car; + const superClass = Racer; + + expect(isSubClass(subClass, superClass)).toBe(true); + }); + + test('상속관계가 아닌 클래스는 false 를 반환한다.', () => { + const subClass = Car; + const etcClass = Race; + + expect(isSubClass(subClass, etcClass)).toBe(false); + }); +}); diff --git a/src/__tests__/validation.test.js b/src/__tests__/validation.test.js deleted file mode 100644 index 47e086c..0000000 --- a/src/__tests__/validation.test.js +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, test, expect } from 'vitest'; -import validation from '../utils/validation'; - -describe('라운드 배열 유효성 검사', () => { - test.each([ - { - rounds: 1, - }, - { rounds: function () {} }, - { rounds: null }, - ])('라운드 배열($rounds)이 배열이 아니면 오류가 발생한다.', ({ rounds }) => { - expect(() => { - validation.isValidateRounds(rounds); - }); - }); -}); - -describe('라운드 유효성 검사', () => { - test('라운드가 숫자 형태가 아니면 오류가 발생한다.', () => { - expect(() => { - validation.isValidateRound('1'); - }).toThrow(); - }), - test('라운드가 1 미만이면 에러가 발생한다.', () => { - expect(() => { - validation.isValidateRound(0); - }).toThrow(); - }), - test('라운드가 50을 초과하면 에러가 발생한다.', () => { - expect(() => { - validation.isValidateRound(51); - }).toThrow(); - }); - test.each([ - { - round: 1, - }, - { round: 50 }, - ])( - '라운드($round)가 1 이상 50 이하의 숫자면 에러를 발생하지 않는다.', - ({ round }) => { - expect(validation.isValidateRound(round)).toBe(true); - } - ); -}); - -describe('자동차 배열 유효성 검사', () => { - test('중복되는 이름이 있으면 에러가 발생한다.', () => { - expect(() => { - validation.isValidateCars(['1', ' 1 ']); - }).toThrow(); - }), - test('자동차 이름이 2개 미만이면 에러가 발생한다.', () => { - expect(() => { - validation.isValidateCars(['suyeon']); - }).toThrow(); - }), - test('자동차 이름이 10개 초과면 에러가 발생한다.', () => { - expect(() => { - validation.isValidateCars([ - '1', - '2', - '3', - '4', - '5', - '6', - '7', - '8', - '9', - '10', - '11', - ]); - }).toThrow(); - }), - test('자동차 이름($)이 중복되지 않고, 2개 이상 10개여야 한다.', () => { - expect(validation.isValidateCars(['suyeon', 'hodu'])).toBe(true); - }); -}); - -describe('자동차 이름 유효성 검사', () => { - test('자동차 이름은 앞 뒤 공백을 제외하고 한 글자 이상, 열글자 이하여야 한다.', () => { - expect(validation.isValidateCarName(' 123 ')).toBe(true); - }), - test('자동차 이름이 앞 뒤 공백을 제외하고 한 글자 미만이면 에러를 발생한다.', () => { - expect(() => { - validation.isValidateCarName(' ').toThrow(); - }); - }), - test('자동차 이름이 앞 뒤 공백을 제외하고 10글자를 초과하면 에러를 발생한다.', () => { - expect(() => { - validation.isValidateCarName('10글자를초과하는완전긴자동차이름!!'); - }).toThrow(); - }); -}); - -describe('자동차 포지션 유효성 검사', () => { - test('포지션이 0 미만인 경우 에러를 발생한다', () => { - expect(() => { - validation.isValidatePosition(-1); - }).toThrow(); - }), - test.each([ - { - position: 'position', - }, - { position: {} }, - { position: [] }, - { position: null }, - ])('포지션이($position) 숫자가 아니면 에러를 발생한다.', ({ position }) => { - expect(() => { - validation.isValidatePosition(position); - }).toThrow(); - }); -}); diff --git a/src/constants/game.js b/src/constants/game.js new file mode 100644 index 0000000..1afc6e5 --- /dev/null +++ b/src/constants/game.js @@ -0,0 +1,11 @@ +export const GAME = { + RULE: { + MAX_ROUND: 10, + MIN_ROUND: 1, + MAX_NAME_LENGTH: 10, + MIN_NAME_LENGTH: 1, + MAX_DICE: 10, + MIN_DICE: 0, + DICE_CONDITION: 4, + }, +}; diff --git a/src/constants/index.js b/src/constants/index.js deleted file mode 100644 index 19507e2..0000000 --- a/src/constants/index.js +++ /dev/null @@ -1,3 +0,0 @@ -export const GAME = { - ROUND: 5, -}; diff --git a/src/main.js b/src/main.js index 10a5ee2..db50c17 100644 --- a/src/main.js +++ b/src/main.js @@ -1,39 +1,55 @@ -import Racing from './model/Racing.js'; -import { GAME } from './constants/index.js'; -import InputView from './view/InputView.js'; -import OutputView from './view/OutputView.js'; -import validation from './utils/validation.js'; - -class Main { - #rounds; - constructor(rounds) { - this.#rounds = this.setRounds(rounds); - } - - setRounds(rounds) { - if (!validation.isValidateRounds(rounds)) { - return []; +import { GAME } from './constants/game.js'; +import Race from './model/Race.js'; +import Car from './model/Car.js'; +import GameView from './view/RaceView.js'; +import gameSupport from './utils/gameSupport.js'; + +async function main() { + const view = new GameView(); + let roundInput = undefined; + let namesInput = undefined; + + while (true) { + roundInput = await view.inputRound(); + const error = Race.getRoundError(roundInput); + + if (error === undefined) { + break; } - - this.#rounds = rounds; - return this.#rounds; + view.printError(error); } - async play() { - const result = this.#rounds.map(async (round) => { - const cars = await InputView.getCarName(); - const racing = new Racing(round, cars); - const winners = racing.startRacing(cars); + while (true) { + namesInput = await view.inputRacerNames(); + const error = Race.getNamesError(namesInput); - OutputView.printWinners(winners); - return winners; - }); - - return result; + if (error === undefined) { + break; + } + view.printError(error); } + + const race = new Race({ + Racer: Car, + round: Number(roundInput), + names: namesInput, + rules: { + diceRule: () => { + return ( + gameSupport.dice(GAME.RULE.MAX_DICE, GAME.RULE.MIN_DICE) >= + GAME.RULE.DICE_CONDITION + ); + }, + }, + onProceed: () => { + view.printMovedCar(race.getRacerStatus()); + }, + }); + + const winners = race.startRace(); + view.printWinners(winners); } -const main = new Main([GAME.ROUND]); -main.play(); +main(); -export default Main; +export default main; diff --git a/src/model/Animal.js b/src/model/Animal.js new file mode 100644 index 0000000..f71da23 --- /dev/null +++ b/src/model/Animal.js @@ -0,0 +1,32 @@ +import Racer from './Racer'; + +export default class Animal extends Racer { + _sound; + + constructor({ name, position = 0, sound }) { + super({ name, position }); + this.sound = sound; + } + + get sound() { + return this._sound; + } + + set sound(sound) { + const soundError = Animal.getSoundError(sound); + + if (soundError !== undefined) { + throw new Error(soundError); + } + + this._sound = sound; + } + + static getSoundError(sound) { + if (typeof sound !== 'string') { + return 'sound 는 문자열 타입이어야 합니다.'; + } + + return undefined; + } +} diff --git a/src/model/Car.js b/src/model/Car.js index b53afbb..6738e90 100644 --- a/src/model/Car.js +++ b/src/model/Car.js @@ -1,40 +1,8 @@ -import validation from '../utils/validation.js'; +import Racer from './Racer.js'; -class Car { - #name; - #position; - - constructor(name) { - this.#name = this.setName(name); - this.#position = 0; - } - - setName(name) { - if (!validation.isValidateCarName(name)) return ''; - - this.#name = name.trim(); - return this.#name; - } - - getName() { - return this.#name; - } - - setPosition(position) { - if (!validation.isValidatePosition(position)) return this.#position; - - this.#position = position; - return this.#position; - } - - getPosition() { - return this.#position; - } - - move(distance = 1) { - this.setPosition(this.#position + distance); - - return this.#position; +class Car extends Racer { + constructor({ name, position = 0 }) { + super({ name, position }); } } diff --git a/src/model/Race.js b/src/model/Race.js new file mode 100644 index 0000000..abb8ef5 --- /dev/null +++ b/src/model/Race.js @@ -0,0 +1,142 @@ +import isSubClass from '../utils/isSubClass.js'; +import Rule from './Rule.js'; +import RacerClass from './Racer.js'; + +export default class Race { + _Racer; + _round; + #rule; + #racers; + + constructor({ Racer, rules, names, round, onProceed }) { + this.Racer = Racer; + this.round = round; + this.#rule = new Rule(rules); + this.#racers = this.#makeRacers(names); + this.onProceed = onProceed; + } + + #makeRacers(names) { + const error = Race.getNamesError(names); + + if (error !== undefined) { + throw new Error(error); + } + + return (names = names + .split(',') + .map((name) => name.trim()) + .map((name) => new this.Racer({ name }))); + } + + get round() { + return this._round; + } + + get Racer() { + return this._Racer; + } + + set round(round) { + const error = Race.getRoundError(round); + + if (error !== undefined) { + throw new Error(error); + } + + this._round = round; + } + + set Racer(Racer) { + const error = Race.getRacerError(Racer); + + if (error !== undefined) { + throw new Error(error); + } + + this._Racer = Racer; + } + + getRacerStatus(name) { + if (name === undefined) { + return this.#racers.map((racer) => ({ + name: racer.name, + position: racer.position, + })); + } + + const targetRacer = this.#racers.find((racer) => racer.name === name); + + return { + name: targetRacer.name, + position: targetRacer.position, + }; + } + + startRace() { + for (let currentRound = 0; currentRound < this.round; currentRound++) { + this.#proceedRound(); + if (this.onProceed && typeof this.onProceed === 'function') { + this.onProceed(); + } + } + + const racers = this.getRacerStatus(); + const maxPosition = Math.max(...racers.map((racer) => racer.position)); + + return racers + .filter((racer) => racer.position === maxPosition) + .map((racer) => racer.name); + } + + #proceedRound() { + this.#racers.forEach((racer) => { + this.#rule.run() && racer.move(); + }); + } + + static getRacerError(Racer) { + if (isSubClass(Racer, RacerClass) === false) { + return '인자로받는 Racer 프로퍼티는 Racer 클래스의 서브 클래스여야 합니다.'; + } + return undefined; + } + + static getRoundError(round) { + round = Number(round); + + if (isNaN(round) === true) { + return '라운드가 숫자 형태가 아닙니다.'; + } + + if (round < 1 || round > 10) { + return '라운드는 1 ~ 10 사이여야 합니다.'; + } + + return undefined; + } + + static getNamesError(names) { + if (typeof names !== 'string') { + return '이름은 , 로 분리된 문자열 형태로 입력해야 합니다.'; + } + + const namesArr = names.split(','); + + if (namesArr.length < 1 || namesArr.length > 10) { + return '레이서의 개수는 1 ~ 10 개여야 합니다'; + } + + if ( + namesArr.some((carName) => { + carName = carName.trim(); + + return carName.length < 1 || carName.length > 10 ? true : false; + }) + ) { + return '이름은 1 글자 이상, 10글자 미만의 문자열이어야 합니다.'; + } + + return undefined; + } +} diff --git a/src/model/Racer.js b/src/model/Racer.js new file mode 100644 index 0000000..99c2a14 --- /dev/null +++ b/src/model/Racer.js @@ -0,0 +1,69 @@ +export default class Racer { + _name; + _position; + + constructor({ name, position }) { + name = name.trim(); + + this.name = name; + this.position = position; + } + + set name(name) { + const error = Racer.getNameError(name); + + if (error !== undefined) { + throw new Error(error); + } + + this._name = name; + } + + set position(position) { + const error = Racer.getPositionError(position); + + if (error !== undefined) { + throw new Error(error); + } + + this._position = position; + } + + get name() { + return this._name; + } + + get position() { + return this._position; + } + + init() { + this._position = 0; + } + + move(distance = 1) { + this.position = this.position + distance; + + return this.position; + } + + static getNameError(name) { + if (typeof name !== 'string') { + return '레이서들의 이름은 문자열이어야 합니다.'; + } + + if (name.length < 1 || name.length > 5) { + return '레이서들의 이름은 1 ~ 5 글자로 이뤄져야 합니다.'; + } + + return undefined; + } + + static getPositionError(position) { + if (typeof position !== 'number') { + return '레이서들의 위치는 숫자 형태여야 합니다.'; + } + + return undefined; + } +} diff --git a/src/model/Racing.js b/src/model/Racing.js deleted file mode 100644 index 7b81e85..0000000 --- a/src/model/Racing.js +++ /dev/null @@ -1,73 +0,0 @@ -import { GAME } from '../constants/index.js'; -import validation from '../utils/validation.js'; -import Car from './Car.js'; - -class Racing { - #round; - #cars; - - constructor(round = GAME.ROUND, cars = []) { - this.#round = this.setRound(round); - this.#cars = this.setCars(cars); - } - - getRound() { - return this.#round; - } - - getCars() { - return this.#cars; - } - - setRound(round) { - if (!validation.isValidateRound(round)) { - return 0; - } - - this.round = round; - return this.round; - } - - setCars(cars) { - cars = typeof cars === 'string' ? cars.split(',') : cars; - - if (!validation.isValidateCars(cars)) { - return []; - } - - this.#cars = cars.map((car) => new Car(car)); - return this.#cars; - } - - startRacing() { - if (!validation.isValidateRacing(this.#cars, this.#round)) { - return []; - } - - for (let currRound = 0; currRound < this.#round; currRound++) { - this.proceedRound(); - } - - return this.getWinners(); - } - - proceedRound() { - this.#cars.forEach((car) => { - Racing.dice() >= 4 ? car.move(1) : car.move(0); - }); - } - - static dice() { - return Math.floor(Math.random() * 10); - } - - getWinners() { - const maxPosition = Math.max(...this.#cars.map((car) => car.getPosition())); - - return this.#cars - .filter((car) => car.getPosition() === maxPosition) - .map((car) => car.getName()); - } -} - -export default Racing; diff --git a/src/model/Rule.js b/src/model/Rule.js new file mode 100644 index 0000000..48c708d --- /dev/null +++ b/src/model/Rule.js @@ -0,0 +1,146 @@ +export default class Rule { + #rules; + + constructor(rules) { + this.#initRules(rules); + } + + #initRules(rules) { + const rulesError = Rule.getRulesError(rules); + + if (rulesError !== undefined) { + throw new Error(rulesError); + } + + const ruleKeys = Object.keys(rules); + + for (const key of ruleKeys) { + const keyError = Rule.getKeyError(key); + const valueError = Rule.getValueError(rules[key]); + + if (keyError !== undefined || valueError !== undefined) { + throw new Error(keyError && valueError); + } + + this.#definePropertyRule(rules, key); + } + + this.#rules = rules; + } + + #definePropertyRule(obj, key) { + let _value = obj[key]; + + Object.defineProperty(obj, key, { + get() { + return _value; + }, + set(value) { + const keyError = Rule.getKeyError(key); + const valueError = Rule.getValueError(value); + + if (keyError !== undefined || valueError !== undefined) { + throw new Error(keyError && valueError); + } + + _value = value; + }, + }); + } + + getKeys() { + return Object.keys(this.#rules); + } + + run() { + const rules = Object.values(this.#rules); + + return rules.every((rule) => rule() === true); + } + + change(key, value) { + if (this.isExist(key) === false) { + throw new Error('해당 이름의 룰이 존재하지 않습니다.'); + } + + this.#rules[key] = value; + } + + add(key, value) { + if (this.isExist(key)) { + throw new Error('이미 같은 이름의 룰이 존재합니다.'); + } + + const nextRule = { ...this.#rules }; + nextRule[key] = value; + + this.#initRules(nextRule); + } + + remove(removeKey) { + const ruleKeys = Object.keys(this.#rules); + const nextRules = {}; + + for (const key of ruleKeys) { + if (removeKey === key) { + continue; + } + + nextRules[key] = this.#rules[key]; + } + + this.#initRules(nextRules); + } + + removeAll() { + this.#rules = {}; + } + + isExist(key) { + return this.#rules[key] === undefined ? false : true; + } + + static getRulesError(rules) { + if (rules !== undefined && typeof rules !== 'object') { + return '룰은 객체여야 합니다.'; + } + + const keys = Object.keys(rules); + const values = Object.values(rules); + const length = keys.length; + + if (length < 1 || 10 < length) { + return '룰은 1 ~ 10 개여야 합니다.'; + } + + if (keys.some((key) => Rule.getKeyError(key) !== undefined)) { + return '룰의 키는 문자열이어야 합니다.'; + } + + if (values.some((value) => Rule.getValueError(value) !== undefined)) { + return '룰의 값은 불리언을 반환하는 함수여야 합니다.'; + } + + return undefined; + } + + static getKeyError(key) { + if (typeof key !== 'string') { + return '룰의 키는 문자열이어야 합니다.'; + } + + return undefined; + } + + static getValueError(value) { + if (typeof value !== 'function') { + return '룰의 값은 함수 타입이어야 합니다.'; + } + + if (typeof value() !== 'boolean') { + return '룰의 반환값은 불리언 타입이어야 합니다.'; + } + + return undefined; + } +} diff --git a/src/utils/gameSupport.js b/src/utils/gameSupport.js new file mode 100644 index 0000000..6773041 --- /dev/null +++ b/src/utils/gameSupport.js @@ -0,0 +1,7 @@ +const gameSupport = { + dice(min, max) { + return Math.floor(Math.random() * (max - min) + min); + }, +}; + +export default gameSupport; diff --git a/src/utils/isSubClass.js b/src/utils/isSubClass.js new file mode 100644 index 0000000..ee3e58a --- /dev/null +++ b/src/utils/isSubClass.js @@ -0,0 +1,15 @@ +function isSubClass(child, parent) { + let prototype = Object.getPrototypeOf(child); + + while (prototype) { + if (prototype === parent) { + return true; + } + + prototype = Object.getPrototypeOf(prototype); + } + + return false; +} + +export default isSubClass; diff --git a/src/utils/validation.js b/src/utils/validation.js deleted file mode 100644 index 677f780..0000000 --- a/src/utils/validation.js +++ /dev/null @@ -1,79 +0,0 @@ -import Car from '../model/Car.js'; - -const validation = { - isValidateRounds: (rounds) => { - if (Array.isArray(rounds) === false) { - throw new Error('rounds 는 배열이어야 합니다.'); - } - if (rounds.some((round) => typeof round !== 'number')) { - throw new Error('rounds 는 숫자 배열이어야 합니다.'); - } - - return true; - }, - isValidateRacing: (cars, round) => { - return ( - cars.every((car) => car instanceof Car) && - validation.isValidateRound(round) - ); - }, - isValidateRound: (round) => { - if (isNaN(round) === true || typeof round !== 'number') { - throw new Error('라운드는 숫자형태여야 합니다.'); - } - if (round < 1 || round > 50) { - throw new Error('라운드 횟수는 1회 이상 50회 이하여야 합니다.'); - } - - return true; - }, - isValidateCars: (cars) => { - if (Array.isArray(cars) === false) { - throw new Error('cars 는 배열이어야 합니다'); - } - if (cars.some((car) => typeof car !== 'string')) { - throw new Error('cars 는 문자열로 이뤄져야 합니다'); - } - - cars = cars.map((car) => car.trim()); - const carSet = new Set(cars); - - if (carSet.size !== cars.length) { - throw new Error('중복되는 이름이 있습니다.'); - } - if (carSet.size < 2 || carSet.size > 10) { - throw new Error('자동차는 2개 이상 10개 이하여야 합니다.'); - } - - return true; - }, - isValidatePosition: (position) => { - if (isNaN(position) || typeof position !== 'number') { - throw new Error('포지션은 숫자여야 합니다'); - } - if (position < 0) { - throw new Error('거리는 최소 0부터 시작해야 합니다.'); - } - - return true; - }, - isValidateCarName: (name) => { - if (typeof name !== 'string') { - throw new Error('자동차의 이름은 문자열 형태여야 합니다.'); - } - name = name.trim(); - - if (name.length === 0) { - throw new Error( - '자동차의 이름은 공백을 제외한 한 글자 이상이어야 합니다.' - ); - } - if (name.length < 1 || name.length > 10) { - throw new Error('자동차 이름은 한 글자 이상 열글자 이하여야 합니다.'); - } - - return true; - }, -}; - -export default validation; diff --git a/src/view/InputView.js b/src/view/InputView.js deleted file mode 100644 index d97b140..0000000 --- a/src/view/InputView.js +++ /dev/null @@ -1,11 +0,0 @@ -import readLineAsync from '../utils/readLine.js'; - -const InputView = { - getCarName: async () => { - return await readLineAsync( - '🏎️ 자동차 이름을 쉼표로 구분지어 입력해주세요! 🏎️ > ' - ); - }, -}; - -export default InputView; diff --git a/src/view/OutputView.js b/src/view/OutputView.js deleted file mode 100644 index 7bfff25..0000000 --- a/src/view/OutputView.js +++ /dev/null @@ -1,7 +0,0 @@ -const OutputView = { - printWinners: (winners) => { - console.log(`✨우승자 ✨ > ${winners}`); - }, -}; - -export default OutputView; diff --git a/src/view/RaceView.js b/src/view/RaceView.js new file mode 100644 index 0000000..5f5c767 --- /dev/null +++ b/src/view/RaceView.js @@ -0,0 +1,34 @@ +import readLineAsync from '../utils/readLine.js'; + +export default class RaceView { + constructor() { + this.rl = readLineAsync; + } + + async inputRound() { + return await this.rl( + '몇번의 라운드를 진행할지 1 ~ 10 사이의 숫자를 입력해주세요! > ', + ); + } + + async inputRacerNames() { + return await this.rl( + '🏎️ 자동차 이름을 쉼표로 구분지어 입력해주세요! 🏎️ > ', + ); + } + + printMovedCar(carInfo) { + carInfo.forEach(({ name, position }) => { + console.log(`${name}: ${'-'.repeat(position)}`); + }); + console.log('\n'); + } + + printWinners(winners) { + console.log(`✨우승자 ✨: ${winners.join(', ')}`); + } + + printError(message) { + console.log(`🚨 ${message} 🚨`); + } +}