diff --git a/README.md b/README.md
index 8d7e8aee..bc58598d 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,24 @@
-# java-baseball-precourse
\ No newline at end of file
+# java-baseball-precourse
+
+## 구현 기능 목록
+
+### core
+
+- [x] 무작위 야구 게임 숫자 생성하는 기능
+- [x] 컴퓨터 숫자, 사용자 숫자 비교해 결과 계산하는 기능
+- [x] 야구 게임 숫자, 재시작 여부를 질의하고 게임 동작을 수행하는 기능
+
+### io
+
+- [x] 평문 string 입력을 야구 게임 숫자로 format 하는 기능
+- [x] 평문 string 입력을 게임 재시작 Yes or no 로 format 하는 기능
+
+- [x] 사용자 야구 게임 숫자 입력받는 기능
+- [x] 사용자 게임 재시작 여부 입력받는 기능
+
+- [x] 컴퓨터 숫자, 사용자 숫자 비교 결과 출력하는 기능
+
+### app
+
+- [x] 게임 시작, 재시작, 종료하는 기능
+- [x] 게임 시작, 종료 구문 보여주는 기능
diff --git a/src/main/java/com/Application.java b/src/main/java/com/Application.java
new file mode 100644
index 00000000..2e68891e
--- /dev/null
+++ b/src/main/java/com/Application.java
@@ -0,0 +1,165 @@
+package com;
+
+import com.core.Calculator;
+import com.core.Game;
+import com.core.NumberGenerator;
+import com.dto.Number;
+import com.dto.Score;
+import com.io.NumberFormatter;
+import com.io.NumberReceiver;
+import com.io.Printer;
+import com.io.YesOrNoFormatter;
+import com.io.YesOrNoReceiver;
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.security.SecureRandom;
+import java.util.Random;
+import java.util.logging.Logger;
+
+public class Application {
+
+ private static final Random RANDOM;
+ private static final int
+ NUMBER_LOWER_BOUND_INCLUSIVE,
+ NUMBER_UPPER_BOUND_EXCLUSIVE;
+ private static final NumberGenerator NUMBER_GENERATOR;
+
+ private static final BufferedReader BUFFERED_READER;
+
+ private static final Printer PRINTER;
+ private static final NumberFormatter NUMBER_FORMATTER;
+ private static final NumberReceiver NUMBER_RECEIVER;
+ private static final YesOrNoFormatter YES_OR_NO_FORMATTER;
+ private static final YesOrNoReceiver YES_OR_NO_RECEIVER;
+
+ private static final Calculator CALCULATOR;
+
+ private static final Logger LOGGER;
+ private static final boolean DEBUG = false;
+
+ static {
+ RANDOM = new SecureRandom();
+
+ NUMBER_LOWER_BOUND_INCLUSIVE = 1;
+ NUMBER_UPPER_BOUND_EXCLUSIVE = 10;
+
+ NUMBER_GENERATOR = new NumberGenerator(
+ RANDOM,
+ NUMBER_LOWER_BOUND_INCLUSIVE, NUMBER_UPPER_BOUND_EXCLUSIVE
+ );
+
+ BUFFERED_READER = new BufferedReader(
+ new InputStreamReader(System.in)
+ );
+
+ PRINTER = new Printer();
+ NUMBER_FORMATTER = new NumberFormatter();
+ NUMBER_RECEIVER = new NumberReceiver(
+ BUFFERED_READER, NUMBER_FORMATTER
+ );
+ YES_OR_NO_FORMATTER = new YesOrNoFormatter();
+ YES_OR_NO_RECEIVER = new YesOrNoReceiver(
+ BUFFERED_READER, YES_OR_NO_FORMATTER
+ );
+
+ CALCULATOR = new Calculator();
+
+ LOGGER = Logger.getLogger(Application.class.getName());
+ }
+
+ public static void main(String[] args) {
+ printWelcomeMessage();
+
+ // game loop 를 만들어 게임을 시작, 종료, 재시작한다.
+ Internal gameLoop = new Internal();
+ while (true) {
+
+ System.out.print("\n------------------------------------------------------------\n");
+
+ boolean retryGame = gameLoop.runGame();
+
+ if (!retryGame) {
+ System.out.print("""
+
+ --<: 게임을 종료합니다. (^^)7
+ """);
+ break;
+ }
+ }
+
+ printGoodbyeMessage();
+ }
+
+ public static void printWelcomeMessage() {
+ System.out.print("""
+ ------------------------------
+ | Hello world! |
+ ------------------------------
+ """);
+ }
+
+ public static void printGoodbyeMessage() {
+ System.out.print("""
+ ------------------------------------------------------------
+
+ --------------------------------
+ | Goodbye world! |
+ --------------------------------
+ """);
+ }
+
+ private static class Internal {
+
+ /**
+ * 한 게임을 진행하고 재시작 여부를 반환한다.
+ *
+ * @return 게임 재시작 여부
+ */
+ private boolean runGame() {
+ System.out.print("""
+ \n--o: 게임 난수 생성중...
+ """);
+
+ // 새로운 게임을 생성한다.
+ Game game = this.initGame();
+
+ System.out.print("""
+ -->: 게임 난수 생성 완료. 숫자 야구 게임이 시작되었습니다!
+
+ """);
+
+ // 게임이 끝날때까지 진행한다.
+ while (true) {
+ Number userNumber = game.queryNumberInputAndReceive();
+
+ Score score = game.calcScoreAndPrintResult(userNumber);
+
+ if (score.isPerfect()) {
+ break;
+ }
+ }
+
+ // 게임 재시작 여부를 반환한다.
+ return game.queryRetryYesOrNoAndReceive();
+ }
+
+ /**
+ * 새로운 게임을 생성해 제공한다.
+ */
+ private Game initGame() {
+ Number gameNumber = NUMBER_GENERATOR.getRandomNumber();
+
+ System.out.println(gameNumber);
+
+ if (DEBUG) {
+ LOGGER.info("Game number :" + gameNumber);
+ }
+
+ return new Game(
+ gameNumber, PRINTER,
+ NUMBER_RECEIVER, YES_OR_NO_RECEIVER,
+ CALCULATOR
+ );
+ }
+ }
+}
diff --git a/src/main/java/com/core/Calculator.java b/src/main/java/com/core/Calculator.java
new file mode 100644
index 00000000..8e2b800b
--- /dev/null
+++ b/src/main/java/com/core/Calculator.java
@@ -0,0 +1,61 @@
+package com.core;
+
+import com.dto.Number;
+import com.dto.Score;
+
+/**
+ * 두 {@link Number} 로 야구 게임 결과를 계산하는 클래스
+ */
+public class Calculator {
+
+ /**
+ * 컴퓨터, 유저 숫자를 비교해 결과를 계산해 제공한다.
+ *
+ * @param gameNumber 컴퓨터 숫자
+ * @param userNumber 유저 숫자
+ */
+ public Score calcScore(Number gameNumber, Number userNumber) {
+ int numOfStrikes = this.calcNumOfStrikes(gameNumber, userNumber);
+ int numOfBalls = this.calcNumOfBalls(gameNumber, userNumber);
+ return new Score(numOfStrikes, numOfBalls);
+ }
+
+ private int calcNumOfStrikes(Number gameNumber, Number userNumber) {
+ int cnt = 0;
+
+ if (gameNumber.first() == userNumber.first()) {
+ cnt++;
+ }
+
+ if (gameNumber.second() == userNumber.second()) {
+ cnt++;
+ }
+
+ if (gameNumber.third() == userNumber.third()) {
+ cnt++;
+ }
+
+ return cnt;
+ }
+
+ private int calcNumOfBalls(Number gameNumber, Number userNumber) {
+ int cnt = 0;
+ int userNumFirst = userNumber.first();
+ int userNumSecond = userNumber.second();
+ int userNumThird = userNumber.third();
+
+ if (gameNumber.first() != userNumFirst && gameNumber.contains(userNumFirst)) {
+ cnt++;
+ }
+
+ if (gameNumber.second() != userNumSecond && gameNumber.contains(userNumSecond)) {
+ cnt++;
+ }
+
+ if (gameNumber.third() != userNumThird && gameNumber.contains(userNumThird)) {
+ cnt++;
+ }
+
+ return cnt;
+ }
+}
diff --git a/src/main/java/com/core/Game.java b/src/main/java/com/core/Game.java
new file mode 100644
index 00000000..a246c00d
--- /dev/null
+++ b/src/main/java/com/core/Game.java
@@ -0,0 +1,84 @@
+package com.core;
+
+import com.dto.Number;
+import com.dto.Score;
+import com.io.NumberReceiver;
+import com.io.Printer;
+import com.io.YesOrNoReceiver;
+import java.util.Optional;
+
+/**
+ * 어느 한 야구 게임을 의미하는 클래스
+ */
+public class Game {
+
+ /**
+ * 게임 정답
+ */
+ private final Number gameNumber;
+
+ private final Printer printer;
+ private final NumberReceiver numberReceiver;
+ private final YesOrNoReceiver yesOrNoReceiver;
+
+ private final Calculator calculator;
+
+ public Game(
+ Number gameNumber, Printer printer,
+ NumberReceiver numberReceiver, YesOrNoReceiver yesOrNoReceiver,
+ Calculator calculator
+ ) {
+ this.gameNumber = gameNumber;
+ this.printer = printer;
+ this.numberReceiver = numberReceiver;
+ this.yesOrNoReceiver = yesOrNoReceiver;
+ this.calculator = calculator;
+ }
+
+ /**
+ * 콘솔로 숫자를 입력받아 제공한다.
+ *
+ * 올바른 입력을 받을때까지 무한정 loop 한다.
+ */
+ public Number queryNumberInputAndReceive() {
+ Number userNumber = null;
+
+ while (userNumber == null) {
+ // 숫자 입력해달라고 출력한다.
+ printer.printNumberInputQuery();
+
+ // 입력 받는다.
+ Optional opt = numberReceiver.receiveInput();
+
+ // 올바르지 않은 입력이다.
+ if (opt.isEmpty()) {
+ printer.printInvalidNumberInput();
+ continue;
+ }
+
+ userNumber = opt.get();
+ }
+
+ return userNumber;
+ }
+
+ /**
+ * 게임 정답과 비교해 결과를 생성하고 콘솔에 출력한다.
+ */
+ public Score calcScoreAndPrintResult(Number userNumber) {
+ Score score = calculator.calcScore(gameNumber, userNumber);
+
+ printer.printScore(score);
+
+ return score;
+ }
+
+ /**
+ * 콘솔로 게임 재시작 여부 입력을 받는다.
+ */
+ public boolean queryRetryYesOrNoAndReceive() {
+ printer.printGameRetryQuery();
+ return yesOrNoReceiver.receiveInput()
+ .orElse(false);
+ }
+}
diff --git a/src/main/java/com/core/NumberGenerator.java b/src/main/java/com/core/NumberGenerator.java
new file mode 100644
index 00000000..4eb31ee0
--- /dev/null
+++ b/src/main/java/com/core/NumberGenerator.java
@@ -0,0 +1,52 @@
+package com.core;
+
+import com.dto.Number;
+import java.util.Random;
+
+/**
+ * 무작위 {@link Number} 생성해주는 클래스
+ */
+public class NumberGenerator {
+
+ private final Random rand;
+ private final int lowerBoundInclusive, upperBoundExclusive;
+
+ /**
+ * @param lowerBoundInclusive {@code bound <= (생성되는 수)}
+ * @param upperBoundExclusive {@code (생성되는 수) < bound}
+ */
+ public NumberGenerator(Random rand, int lowerBoundInclusive, int upperBoundExclusive) {
+ this.rand = rand;
+ this.lowerBoundInclusive = lowerBoundInclusive;
+ this.upperBoundExclusive = upperBoundExclusive;
+
+ if (lowerBoundInclusive >= upperBoundExclusive) {
+ throw new IllegalArgumentException("Lower bound must less than upper bound");
+ }
+ }
+
+ /**
+ * 랜덤한 {@link Number} 를 생성해 제공한다.
+ */
+ public Number getRandomNumber() {
+ boolean[] recordTable = new boolean[upperBoundExclusive - lowerBoundInclusive];
+
+ int first = getRandomNumberExcept(recordTable);
+ int second = getRandomNumberExcept(recordTable);
+ int third = getRandomNumberExcept(recordTable);
+
+ return new Number(first, second, third);
+ }
+
+ private int getRandomNumberExcept(boolean[] numberTable) {
+ while (true) {
+ int randNum = rand.nextInt(lowerBoundInclusive, upperBoundExclusive);
+ int i = randNum - lowerBoundInclusive;
+
+ if (!numberTable[i]) {
+ numberTable[i] = true;
+ return randNum;
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/dto/Number.java b/src/main/java/com/dto/Number.java
new file mode 100644
index 00000000..f455c42a
--- /dev/null
+++ b/src/main/java/com/dto/Number.java
@@ -0,0 +1,14 @@
+package com.dto;
+
+/**
+ * 야구 게임의 3 자리 숫자
+ */
+public record Number(int first, int second, int third) {
+
+ /**
+ * 제공한 {@code number} 가 포함되어 있는지 여부
+ */
+ public boolean contains(int number) {
+ return number == first || number == second || number == third;
+ }
+}
diff --git a/src/main/java/com/dto/Score.java b/src/main/java/com/dto/Score.java
new file mode 100644
index 00000000..be94b754
--- /dev/null
+++ b/src/main/java/com/dto/Score.java
@@ -0,0 +1,35 @@
+package com.dto;
+
+/**
+ * 한 야구 게임 제출에 대한 결과
+ */
+public record Score(int numOfStrikes, int numOfBalls) {
+
+ public Score {
+ if (
+ numOfStrikes < 0 || numOfStrikes > 3
+ ) {
+ throw new IllegalArgumentException("Strikes must be [0, 3] range");
+ }
+
+ if (
+ numOfBalls < 0 || numOfBalls > 3
+ ) {
+ throw new IllegalArgumentException("Balls must be [0, 3] range");
+ }
+ }
+
+ /**
+ * {@code 0 점} 인지에 대한 여부
+ */
+ public boolean isNothing() {
+ return numOfStrikes + numOfBalls == 0;
+ }
+
+ /**
+ * {@code 만점} 인지에 대한 여부
+ */
+ public boolean isPerfect() {
+ return numOfStrikes == 3;
+ }
+}
diff --git a/src/main/java/com/exception/InvalidFormatException.java b/src/main/java/com/exception/InvalidFormatException.java
new file mode 100644
index 00000000..671d6821
--- /dev/null
+++ b/src/main/java/com/exception/InvalidFormatException.java
@@ -0,0 +1,8 @@
+package com.exception;
+
+public class InvalidFormatException extends RuntimeException {
+
+ public InvalidFormatException(String message) {
+ super(message);
+ }
+}
diff --git a/src/main/java/com/io/AbstractInputReceiver.java b/src/main/java/com/io/AbstractInputReceiver.java
new file mode 100644
index 00000000..4855752f
--- /dev/null
+++ b/src/main/java/com/io/AbstractInputReceiver.java
@@ -0,0 +1,59 @@
+package com.io;
+
+import com.exception.InvalidFormatException;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.util.Optional;
+import java.util.logging.Logger;
+
+/**
+ * 콘솔 입력을 {@code T} 로 만들어 주는 추상 클래스
+ *
+ * @param format 되길 원하는 형태
+ * @see Formatter
+ */
+public abstract class AbstractInputReceiver {
+
+ private final BufferedReader br;
+ private final Formatter formatter;
+
+ private final Logger log;
+ private final boolean DEBUG;
+
+ protected AbstractInputReceiver(BufferedReader br, Formatter formatter) {
+ this(br, formatter, false);
+ }
+
+ protected AbstractInputReceiver(BufferedReader br, Formatter formatter, boolean debug) {
+ this.br = br;
+ this.formatter = formatter;
+ DEBUG = debug;
+ this.log = Logger.getLogger(this.getClass().getName());
+ }
+
+ /**
+ * IO 로부터 입력받아 원하는 형태로 format, Optional 로 감싸 제공하는 메서드
+ *
+ * @return format 하다 실패했을 땐 {@code Optional.empty()}
+ * @see Formatter#format(String)
+ */
+ public final Optional receiveInput() {
+ T result = null;
+
+ try {
+ String input = br.readLine(); // io 로 입력을 받아
+ result = formatter.format(input); // formatter 로 정제한다.
+ } catch (IOException | InvalidFormatException e) {
+ // 에러 터지면 유감
+ if (DEBUG) {
+ log.info(String.format(
+ "Failed to format input: (%s)%s",
+ e.getClass().getSimpleName(),
+ e.getMessage()
+ ));
+ }
+ }
+
+ return Optional.ofNullable(result);
+ }
+}
diff --git a/src/main/java/com/io/Formatter.java b/src/main/java/com/io/Formatter.java
new file mode 100644
index 00000000..2b901a68
--- /dev/null
+++ b/src/main/java/com/io/Formatter.java
@@ -0,0 +1,21 @@
+package com.io;
+
+import com.exception.InvalidFormatException;
+
+/**
+ * 평문 문자열 {@code (input)} 을 원하는 형태로 format 하는 방법에 대한 계약
+ *
+ * @param format 되길 원하는 형태
+ */
+public interface Formatter {
+
+ /**
+ * new line input 으로부터 원하는 {@code T} 로 format 하는 메서드
+ *
+ * Format 안맞으면 그냥 Ex throw 하거나 {@code null} return
+ *
+ * @param input {@code br.readLine()} 으로 받은 new line
+ * @throws InvalidFormatException {@code input} 으로부터 {@code T} 를 만들 수 없을 때
+ */
+ T format(String input) throws InvalidFormatException;
+}
diff --git a/src/main/java/com/io/NumberFormatter.java b/src/main/java/com/io/NumberFormatter.java
new file mode 100644
index 00000000..714a53fb
--- /dev/null
+++ b/src/main/java/com/io/NumberFormatter.java
@@ -0,0 +1,54 @@
+package com.io;
+
+import com.dto.Number;
+import com.exception.InvalidFormatException;
+
+/**
+ * 콘솔 입력을 {@link Number} 로 format 해주는 클래스
+ */
+public class NumberFormatter implements Formatter {
+
+ private static final char ZERO = '0';
+
+ @Override
+ public Number format(String input) throws InvalidFormatException {
+ if (
+ input == null ||
+ (input = input.trim()).length() != 3
+ ) {
+ throw new InvalidFormatException("Invalid input");
+ }
+
+ int first, second, third;
+
+ // input 에서 각 자리 숫자를 가져온다.
+ first = parseNumberOnIndex(input, 0);
+ second = parseNumberOnIndex(input, 1);
+ third = parseNumberOnIndex(input, 2);
+
+ // 3 숫자는 서로 다른 숫자여야 한다.
+ if (first == second || second == third || first == third) {
+ throw new InvalidFormatException("Invalid input");
+ }
+
+ return new Number(first, second, third);
+ }
+
+ private int parseNumberOnIndex(String str, int index) {
+ if (str.length() <= index) {
+ throw new InvalidFormatException("Invalid number format index");
+ }
+
+ char c = str.charAt(index);
+
+ if (!Character.isDigit(c)) {
+ throw new InvalidFormatException("Not a number");
+ }
+
+ if (c <= ZERO) {
+ throw new InvalidFormatException("Only 1 - 9 digits available");
+ }
+
+ return c - ZERO;
+ }
+}
diff --git a/src/main/java/com/io/NumberReceiver.java b/src/main/java/com/io/NumberReceiver.java
new file mode 100644
index 00000000..ef09992f
--- /dev/null
+++ b/src/main/java/com/io/NumberReceiver.java
@@ -0,0 +1,14 @@
+package com.io;
+
+import com.dto.Number;
+import java.io.BufferedReader;
+
+/**
+ * 야구 게임 숫자를 입력 받아 {@link Number} 로 format 해 제공하는 클래스
+ */
+public class NumberReceiver extends AbstractInputReceiver {
+
+ public NumberReceiver(BufferedReader br, NumberFormatter numberFormatter) {
+ super(br, numberFormatter);
+ }
+}
diff --git a/src/main/java/com/io/Printer.java b/src/main/java/com/io/Printer.java
new file mode 100644
index 00000000..5c72c700
--- /dev/null
+++ b/src/main/java/com/io/Printer.java
@@ -0,0 +1,63 @@
+package com.io;
+
+import com.dto.Score;
+
+public class Printer {
+
+ @SuppressWarnings("unused")
+ public static final String
+ RED = "\u001B[31m",
+ GREEN = "\u001B[32m",
+ YELLOW = "\u001B[33m",
+ BLUE = "\u001B[34m",
+ MAGENTA = "\u001B[35m",
+ CYAN = "\u001B[36m",
+ RESET = "\33[39m";
+
+
+ public void printNumberInputQuery() {
+ System.out.printf("""
+ %s----<: 숫자를 입력해주세요 :%s\s""",
+ CYAN, RESET);
+ }
+
+ public void printInvalidNumberInput() {
+ System.out.printf("""
+ %s----x: 입력이 올바르지 않습니다... (x.x)
+ : 1 - 9 범위의 숫자 3 개를 중복없이 제공해 주세요!%s
+
+ """,
+ RED, RESET
+ );
+ }
+
+ public void printScore(Score score) {
+ if (score.isNothing()) {
+ System.out.printf("""
+ %s---->: [ Nothing ] \t아무런 숫자도 겹치지 않네요 \\(i,i)/%s
+
+ """,
+ YELLOW, RESET
+ );
+ return;
+ }
+
+ int numOfStrikes = score.numOfStrikes();
+ int numOfBalls = score.numOfBalls();
+
+ System.out.printf("""
+ %s---->: [ 스트라이크 : %1d, 볼 : %1d ] (O.O)%s
+
+ """,
+ BLUE, numOfStrikes, numOfBalls, RESET
+ );
+ }
+
+ public void printGameRetryQuery() {
+ System.out.printf("""
+ %s----o: 정답입니다! (^o^)
+ : 게임을 다시 시작하시겠습니까? (Y):%s\s""",
+ MAGENTA, RESET
+ );
+ }
+}
diff --git a/src/main/java/com/io/YesOrNoFormatter.java b/src/main/java/com/io/YesOrNoFormatter.java
new file mode 100644
index 00000000..3110c23b
--- /dev/null
+++ b/src/main/java/com/io/YesOrNoFormatter.java
@@ -0,0 +1,29 @@
+package com.io;
+
+import com.exception.InvalidFormatException;
+
+/**
+ * 콘솔 입력을 {@code true(yes)} or {@code false(no)} 로 format 해 제공하는 클래스
+ */
+public class YesOrNoFormatter implements Formatter {
+
+ @Override
+ public Boolean format(String input) throws InvalidFormatException {
+ boolean result = false;
+
+ if (input != null && !input.isEmpty()) {
+
+ // 확실히 yes 인 것들만 추려낸다.
+ if (
+ input.equals("Y") ||
+ input.equals("YES") ||
+ input.equals("y") ||
+ input.equals("yes")
+ ) {
+ result = true;
+ }
+ }
+
+ return result;
+ }
+}
diff --git a/src/main/java/com/io/YesOrNoReceiver.java b/src/main/java/com/io/YesOrNoReceiver.java
new file mode 100644
index 00000000..aaa1c4fe
--- /dev/null
+++ b/src/main/java/com/io/YesOrNoReceiver.java
@@ -0,0 +1,13 @@
+package com.io;
+
+import java.io.BufferedReader;
+
+/**
+ * 야구 게임을 완료했을 때 재시작 입력을 받아 {@code Boolean} 으로 format 해 제공하는 클래스
+ */
+public class YesOrNoReceiver extends AbstractInputReceiver {
+
+ public YesOrNoReceiver(BufferedReader br, YesOrNoFormatter yesOrNoFormatter) {
+ super(br, yesOrNoFormatter);
+ }
+}
diff --git a/src/test/java/com/core/CalculatorTest.java b/src/test/java/com/core/CalculatorTest.java
new file mode 100644
index 00000000..1f94e1ae
--- /dev/null
+++ b/src/test/java/com/core/CalculatorTest.java
@@ -0,0 +1,183 @@
+package com.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.dto.Number;
+import com.dto.Score;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class CalculatorTest {
+
+ private static final Calculator calculator = new Calculator();
+
+ private static Stream strikeArguments() {
+ return Stream.of(
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 0, 0), 0
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(1, 0, 0), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 2, 0), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 0, 3), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(1, 2, 0), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(1, 0, 3), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 2, 3), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(1, 2, 3), 3
+ )
+ );
+ }
+
+ private static Stream ballArguments() {
+ return Stream.of(
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 0, 0), 0
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 1, 0), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 0, 1), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(2, 0, 0), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 0, 2), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(3, 0, 0), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 3, 0), 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 1, 2), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(2, 0, 1), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(2, 1, 0), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 3, 1), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(3, 0, 1), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(3, 1, 0), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(0, 3, 2), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(2, 3, 0), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(3, 0, 2), 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(3, 1, 2), 3
+ )
+ );
+ }
+
+ private static Stream combinedArguments() {
+ return Stream.of(
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(1, 0, 2),
+ 1, 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(1, 3, 6),
+ 1, 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(3, 2, 0),
+ 1, 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(8, 2, 1),
+ 1, 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(8, 1, 3),
+ 1, 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(2, 0, 3),
+ 1, 1
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(1, 3, 2),
+ 1, 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(3, 2, 1),
+ 1, 2
+ ),
+ Arguments.of(
+ genNumber(1, 2, 3), genNumber(2, 1, 3),
+ 1, 2
+ )
+ );
+ }
+
+ private static Number genNumber(int first, int second, int third) {
+ return new Number(first, second, third);
+ }
+
+ @ParameterizedTest
+ @MethodSource("strikeArguments")
+ @DisplayName("두 Number 사이 스트라이크 개수를 계산할 수 있다.")
+ void testStrikes(Number gameNumber, Number userNumber, int expectedStrikes) {
+
+ Score result = calculator.calcScore(gameNumber, userNumber);
+
+ assertThat(result).isNotNull();
+ assertThat(result.numOfStrikes()).isEqualTo(expectedStrikes);
+ }
+
+ @ParameterizedTest
+ @MethodSource("ballArguments")
+ @DisplayName("두 Number 사이 볼 개수를 계산할 수 있다.")
+ void testBalls(Number gameNumber, Number userNumber, int expectedBalls) {
+
+ Score result = calculator.calcScore(gameNumber, userNumber);
+
+ assertThat(result).isNotNull();
+ assertThat(result.numOfBalls()).isEqualTo(expectedBalls);
+ }
+
+ @ParameterizedTest
+ @MethodSource("combinedArguments")
+ @DisplayName("두 Number 사이 스트라이크, 볼 개수를 계산할 수 있다.")
+ void testCombinedCase(
+ Number gameNumber, Number userNumber,
+ int expectedStrikes, int expectedBalls
+ ) {
+
+ Score result = calculator.calcScore(gameNumber, userNumber);
+
+ assertThat(result).isNotNull();
+ assertThat(result.numOfStrikes()).isEqualTo(expectedStrikes);
+ assertThat(result.numOfBalls()).isEqualTo(expectedBalls);
+ }
+}
diff --git a/src/test/java/com/core/GameTest.java b/src/test/java/com/core/GameTest.java
new file mode 100644
index 00000000..f1f21289
--- /dev/null
+++ b/src/test/java/com/core/GameTest.java
@@ -0,0 +1,218 @@
+package com.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.dto.Number;
+import com.io.NumberFormatter;
+import com.io.NumberReceiver;
+import com.io.Printer;
+import com.io.YesOrNoFormatter;
+import com.io.YesOrNoReceiver;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Queue;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class GameTest {
+
+ private static final MockedPrinter mockedPrinter;
+ private static final MockedBufferedReader mockedBufferedReader;
+
+ private static final NumberFormatter numberFormatter;
+ private static final NumberReceiver numberReceiver;
+ private static final YesOrNoFormatter yesOrNoFormatter;
+ private static final YesOrNoReceiver yesOrNoReceiver;
+
+ private static final Calculator calculator;
+
+ static {
+ mockedPrinter = new MockedPrinter();
+ mockedBufferedReader = new MockedBufferedReader();
+
+ numberFormatter = new NumberFormatter();
+ yesOrNoFormatter = new YesOrNoFormatter();
+
+ numberReceiver = new NumberReceiver(
+ mockedBufferedReader, numberFormatter
+ );
+ yesOrNoReceiver = new YesOrNoReceiver(
+ mockedBufferedReader, yesOrNoFormatter
+ );
+
+ calculator = new Calculator();
+ }
+
+ @AfterEach
+ void tearDown() {
+ mockedPrinter.initCallCnt();
+ mockedBufferedReader
+ .initResponses()
+ .initMethodCallCnt();
+ }
+
+ @Test
+ @DisplayName("올바른 숫자 입력이 제공될때까지 입력을 게속 받는다.")
+ void testNumberInput() {
+ List invalidInputs = Arrays.asList(
+ "", " ",
+ "1 ", " 1", " 1 ", "a", "0", "1", "?", "/",
+ "10", "11", "1a", "a1", "1 ",
+ "120", "121", "1 3", "1a3"
+ );
+ String validInput = "123";
+
+ // input 을 setup 해둔다.
+ mockedBufferedReader
+ .addResponses(invalidInputs)
+ .addResponse(validInput);
+
+ int invalidInputTestSize = invalidInputs.size();
+ int testSize = invalidInputTestSize + 1;
+
+ // game 만들어서 number input 을 넣어본다.
+ Game game = this.createGame();
+ game.queryNumberInputAndReceive();
+
+ // 예상대로 돌아갔는지 검증한다.
+ assertThat(mockedPrinter.getPrintInvalidNumberInputCallCnt())
+ .isEqualTo(invalidInputTestSize);
+ assertThat(mockedPrinter.getPrintNumberInputQueryCallCnt())
+ .isEqualTo(testSize);
+
+ assertThat(mockedBufferedReader.getMethodCallCnt())
+ .isEqualTo(testSize);
+ }
+
+ @Test
+ @DisplayName("확실히 Yes 가 아닌 입력들은 모두 No 로 처리된다.")
+ void testYesOrNoInput() {
+ List noInputs = Arrays.asList(
+ "", " ",
+ "1 ", " 1", " 1 ", "a", "0", "1", "?", "/",
+ "10", "11", "1a", "a1", "1 ",
+ "120", "121", "1 3", "1a3"
+ );
+ List yesInputs = Arrays.asList(
+ "y", "yes", "Y", "YES"
+ );
+
+ // input 을 setup 해둔다.
+ mockedBufferedReader
+ .addResponses(noInputs)
+ .addResponses(yesInputs);
+
+ int noInputTestSize = noInputs.size();
+ int yesInputTestSize = yesInputs.size();
+
+ // game 만들어서
+ Game game = this.createGame();
+
+ // yes or no input 을 넣어 결과를 검증한다.
+ for (int i = 0; i < noInputTestSize; i++) {
+ assertThat(game.queryRetryYesOrNoAndReceive()).isFalse();
+ }
+
+ for (int i = 0; i < yesInputTestSize; i++) {
+ assertThat(game.queryRetryYesOrNoAndReceive()).isTrue();
+ }
+ }
+
+ Game createGame() {
+ return new Game(
+ new Number(1, 2, 3),
+ mockedPrinter, numberReceiver, yesOrNoReceiver,
+ calculator
+ );
+ }
+}
+
+class MockedPrinter extends Printer {
+
+ private int
+ printNumberInputQueryCallCnt,
+ printInvalidNumberInputCallCnt;
+
+ @Override
+ public void printNumberInputQuery() {
+ printNumberInputQueryCallCnt++;
+ }
+
+ @Override
+ public void printInvalidNumberInput() {
+ printInvalidNumberInputCallCnt++;
+ }
+
+ @Override
+ public void printGameRetryQuery() {
+ // ignore
+ }
+
+ public int getPrintNumberInputQueryCallCnt() {
+ return printNumberInputQueryCallCnt;
+ }
+
+ public int getPrintInvalidNumberInputCallCnt() {
+ return printInvalidNumberInputCallCnt;
+ }
+
+ public void initCallCnt() {
+ printInvalidNumberInputCallCnt = 0;
+ }
+}
+
+@SuppressWarnings("UnusedReturnValue")
+class MockedBufferedReader extends BufferedReader {
+
+ private final Queue responseList;
+ private int methodCallCnt;
+
+ public MockedBufferedReader() {
+ super(new InputStreamReader(System.in));
+ responseList = new ArrayDeque<>();
+ }
+
+ @Override
+ @SuppressWarnings("RedundantThrows")
+ public String readLine() throws IOException {
+
+ if (responseList.isEmpty()) {
+ throw new AssertionError(
+ "No response has been mocked on method: "
+ + "BufferedReader#readLine"
+ );
+ }
+
+ methodCallCnt++;
+ return responseList.poll();
+ }
+
+ public MockedBufferedReader addResponse(String response) {
+ responseList.add(response);
+ return this;
+ }
+
+ public MockedBufferedReader addResponses(List responses) {
+ responseList.addAll(responses);
+ return this;
+ }
+
+ public MockedBufferedReader initResponses() {
+ responseList.clear();
+ return this;
+ }
+
+ public int getMethodCallCnt() {
+ return methodCallCnt;
+ }
+
+ public MockedBufferedReader initMethodCallCnt() {
+ methodCallCnt = 0;
+ return this;
+ }
+}
diff --git a/src/test/java/com/core/NumberGeneratorTest.java b/src/test/java/com/core/NumberGeneratorTest.java
new file mode 100644
index 00000000..5f512978
--- /dev/null
+++ b/src/test/java/com/core/NumberGeneratorTest.java
@@ -0,0 +1,37 @@
+package com.core;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.dto.Number;
+import java.util.Random;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+class NumberGeneratorTest {
+
+ private static final int TEST_SIZE = 100,
+ lowerBoundInclusive = 1,
+ upperBoundExclusive = 10;
+ private static final NumberGenerator randNumberGenerator
+ = new NumberGenerator(
+ new Random(), lowerBoundInclusive, upperBoundExclusive
+ );
+
+ @Test
+ @DisplayName("1 부터 9 까지 서로 다른 수로 이루어진 3 자리 수를 생성할 수 있다.")
+ void getRandomNumber() {
+ for (int i = 0; i < TEST_SIZE; i++) {
+ Number result = randNumberGenerator.getRandomNumber();
+
+ assertThat(result).isNotNull().hasNoNullFieldsOrProperties();
+
+ int first = result.first();
+ int second = result.second();
+ int third = result.third();
+
+ assertThat(first).isNotEqualTo(second);
+ assertThat(second).isNotEqualTo(third);
+ assertThat(first).isNotEqualTo(third);
+ }
+ }
+}
diff --git a/src/test/java/com/io/NumberFormatterTest.java b/src/test/java/com/io/NumberFormatterTest.java
new file mode 100644
index 00000000..5ff344bf
--- /dev/null
+++ b/src/test/java/com/io/NumberFormatterTest.java
@@ -0,0 +1,69 @@
+package com.io;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.dto.Number;
+import com.exception.InvalidFormatException;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class NumberFormatterTest {
+
+ private static final NumberFormatter formater = new NumberFormatter();
+
+ private static Stream validArguments() {
+ return Stream.of(
+ Arguments.of("123", new Number(1, 2, 3)),
+ Arguments.of("321", new Number(3, 2, 1)),
+ Arguments.of("456", new Number(4, 5, 6)),
+ Arguments.of("654", new Number(6, 5, 4)),
+ Arguments.of("789", new Number(7, 8, 9)),
+ Arguments.of("987", new Number(9, 8, 7))
+ );
+ }
+
+ private static Stream invalidArguments() {
+ return Stream.of(
+ Arguments.of("012"),
+ Arguments.of("102"),
+ Arguments.of("120"),
+ Arguments.of("121"),
+ Arguments.of("122"),
+ Arguments.of("221"),
+ Arguments.of("222"),
+ Arguments.of("a12"),
+ Arguments.of("1 2"),
+ Arguments.of("12."),
+ Arguments.of("abc"),
+ Arguments.of("1"),
+ Arguments.of("12"),
+ Arguments.of(""),
+ Arguments.of(" "),
+ Arguments.of((Object) null)
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("validArguments")
+ @DisplayName("숫자 형식의 입력을 format 할 수 있다.")
+ void format(String input, Number expected) {
+ Number result = formater.format(input);
+
+ assertThat(result).isNotNull()
+ .hasNoNullFieldsOrProperties()
+ .isEqualTo(expected);
+ }
+
+ @ParameterizedTest
+ @MethodSource("invalidArguments")
+ @DisplayName("올바르지 않은 입력은 InvalidFormatException 에러를 일으킨다.")
+ void testInvalidFormatException(String input) {
+
+ assertThatThrownBy(() -> formater.format(input))
+ .isInstanceOf(InvalidFormatException.class);
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/com/io/YesOrNoFormatterTest.java b/src/test/java/com/io/YesOrNoFormatterTest.java
new file mode 100644
index 00000000..f6c5cc7a
--- /dev/null
+++ b/src/test/java/com/io/YesOrNoFormatterTest.java
@@ -0,0 +1,55 @@
+package com.io;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.stream.Stream;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class YesOrNoFormatterTest {
+
+ private static final YesOrNoFormatter formatter = new YesOrNoFormatter();
+
+ private static Stream yesArguments() {
+ return Stream.of(
+ Arguments.of("yes"),
+ Arguments.of("y"),
+ Arguments.of("YES"),
+ Arguments.of("Y")
+ );
+ }
+
+ private static Stream noArguments() {
+ return Stream.of(
+ Arguments.of("no"),
+ Arguments.of("n"),
+ Arguments.of("this is invalid"),
+ Arguments.of("i am Groot"),
+ Arguments.of(""),
+ Arguments.of(" "),
+ Arguments.of((Object) null)
+ );
+ }
+
+ @ParameterizedTest
+ @MethodSource("yesArguments")
+ @DisplayName("Yes 입력을 format 할 수 있다.")
+ void format(String input) {
+
+ Boolean result = formatter.format(input);
+
+ assertThat(result).isNotNull().isTrue();
+ }
+
+ @ParameterizedTest
+ @MethodSource("noArguments")
+ @DisplayName("정확히 YES 가 아닌 모든 입력은 false 로 format 된다.")
+ void testNoFormats(String input) {
+
+ Boolean result = formatter.format(input);
+
+ assertThat(result).isNotNull().isFalse();
+ }
+}