diff --git a/README.md b/README.md index b4b1377..c20b917 100644 --- a/README.md +++ b/README.md @@ -55,3 +55,35 @@ spill 🟩🟩⬜🟩🟩 🟩🟩🟩🟩🟩 ``` +## πŸ“„κΈ°λŠ₯ λͺ©λ‘ + +### 도메인 +#### κ²Œμž„ + - [x] μ‹œλ„ 회수λ₯Ό λ‹€ μ‚¬μš©ν•˜λ©΄ κ²Œμž„μ€ νŒ¨λ°°ν•œλ‹€. + - [x] κ²Œμž„ ν•œ ν„΄ λ‹Ή κ²°κ³Όλ₯Ό 계산 및 μ €μž₯ν•œλ‹€ + +#### 문제 + - [x] words.txt μ•ˆμ— μ‘΄μž¬ν•΄μ•Όν•œλ‹€. + - [x] λ¬Έμ œλŠ” 맀일 λ°”λ€Œμ–΄μ•Όν•œλ‹€. + - [x] ((ν˜„μž¬ λ‚ μ§œ - 2021λ…„ 6μ›” 19일) % 단어 μ „μ²΄μ˜ 개수) 번째의 단어 + - [x] 정닡을 λ§žμΆ”λ©΄ true, 틀리면 false λ₯Ό λ°˜ν™˜ν•œλ‹€. + - [x] 힌트λ₯Ό λ°˜ν™˜ν•œλ‹€ + - [x] 처음 Green 을 κ²€μ‚¬ν•œλ‹€.(같은 μΈλ±μŠ€μ™€μ˜ 문자 검사) + - [x] Green이 맞으면 μ •λ‹΅κ³Ό μž…λ ₯μ—μ„œ ν•΄λ‹Ή 값을 빈 걸둜 μΉ˜ν™˜ν•œλ‹€. + - [x] μˆœνšŒν•˜λ©΄μ„œ λ‚˜λ¨Έμ§€λ₯Ό κ²€μ‚¬ν•œλ‹€. + - [x] Yelloκ°€ 맞으면 μ •λ‹΅κ³Ό μž…λ ₯μ—μ„œ ν•΄λ‹Ή 값을 빈 걸둜 μΉ˜ν™˜ν•œλ‹€. + +#### μ‹œλ„ 회수 + - [x] μ‹œλ„ νšŒμˆ˜λŠ” 총 6번 μ£Όμ›Œμ§„λ‹€. + - [x] λͺ‡ 번 μ‹œλ„ν•˜μ˜€λŠ”μ§€λ₯Ό μ €μž₯ν•œλ‹€. + +### μž…λ ₯ + - [x] 정닡을 μž…λ ₯λ°›λŠ”λ‹€. + - [x] μ‚¬μš©μžκ°€ 5κΈ€μžλ₯Ό μž…λ ₯ν•˜μ§€ μ•ŠμœΌλ©΄ μž¬μž…λ ₯을 μš”κ΅¬ν•œλ‹€. + - [x] μ‚¬μš©μžκ°€ μ˜μ–΄ μ΄μ™Έμ˜ κΈ€μžλ₯Ό μž…λ ₯ν•˜λ©΄ μž¬μž…λ ₯을 μš”κ΅¬ν•œλ‹€. + - [x] word.txt μ•ˆμ— μ‘΄μž¬ν•˜λŠ” 단어λ₯Ό μž…λ ₯ν•˜μ§€ μ•ŠμœΌλ©΄ μž¬μž…λ ₯을 μš”κ΅¬ν•œλ‹€. + +### 좜λ ₯ + - [x] νƒ€μΌμ—λŠ” μ΄ˆλ‘μƒ‰/λ…Έλž€μƒ‰/νšŒμƒ‰μ΄ μ‘΄μž¬ν•œλ‹€ + - [x] μ΅œμ’… μ‹œλ„ 횟수λ₯Ό 좜λ ₯ν•œλ‹€. + diff --git a/src/main/kotlin/WordleApplication.kt b/src/main/kotlin/WordleApplication.kt new file mode 100644 index 0000000..f2808e1 --- /dev/null +++ b/src/main/kotlin/WordleApplication.kt @@ -0,0 +1,8 @@ +import controller.WordleController +import view.InputView +import view.OutputView + +fun main() { + val wordleController = WordleController(InputView(), OutputView()) + wordleController.play() +} diff --git a/src/main/kotlin/controller/WordleController.kt b/src/main/kotlin/controller/WordleController.kt new file mode 100644 index 0000000..1e42bfd --- /dev/null +++ b/src/main/kotlin/controller/WordleController.kt @@ -0,0 +1,61 @@ +package controller + +import domain.Hint +import domain.Question +import domain.TodayWordDictionary +import domain.TryCount +import view.InputView +import view.OutputView + +class WordleController(val inputView: InputView, val outputView: OutputView) { + + private val wordDictionary = TodayWordDictionary() + private val question: Question = Question(wordDictionary) + private val maxTryCount: TryCount = TryCount(MAX_TRY_COUNT) + private var tryCount: TryCount = TryCount() + + fun play() { + val result: MutableList> = mutableListOf() + outputView.printWelcomeMessage() + + while (isGameNotEnd()) { + val userAnswer: String = readAnswer() + val hint: List = question.getHint(userAnswer) + + result.add(hint) + tryCount = tryCount.plus() + + if (question.isAnswer(userAnswer)) { + outputView.printTryCount(tryCount.tryCount, maxTryCount.tryCount) + outputView.printHint(result) + return + } + + outputView.printHint(result) + } + } + + fun readAnswer(): String { + return repeatInput { + val readAnswer = inputView.readAnswer() + require(wordDictionary.contains(readAnswer)) { "word.txt λ‚΄μ˜ 단어λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”" } + readAnswer + } + } + + fun isGameNotEnd(): Boolean { + return !maxTryCount.isSame(tryCount) + } + + private fun repeatInput(input: () -> T): T { + return runCatching { input() } + .getOrElse { e -> + outputView.printErrorMessage(e.message) + repeatInput(input) + } + } + + companion object { + private const val MAX_TRY_COUNT = 6 + } +} diff --git a/src/main/kotlin/domain/Hint.kt b/src/main/kotlin/domain/Hint.kt new file mode 100644 index 0000000..888f041 --- /dev/null +++ b/src/main/kotlin/domain/Hint.kt @@ -0,0 +1,7 @@ +package domain + +enum class Hint { + GRAY, + YELLOW, + GREEN, +} diff --git a/src/main/kotlin/domain/Question.kt b/src/main/kotlin/domain/Question.kt new file mode 100644 index 0000000..2eb1df9 --- /dev/null +++ b/src/main/kotlin/domain/Question.kt @@ -0,0 +1,64 @@ +package domain + +class Question(private val wordDictionary: WordDictionary) { + + private val questionWord: String = wordDictionary.pickWord() + + fun isAnswer(word: String): Boolean { + validateWord(word) + return checkAnswer(word) + } + + private fun validateWord(word: String) { + require(wordDictionary.contains(word)) { word + NO_SUCH_WORD_MESSAGE } + } + + private fun checkAnswer(word: String): Boolean { + return questionWord == word + } + + fun getHint(word: String): List { + validateWord(word) + val hint = MutableList(questionWord.length) { Hint.GRAY }.toMutableList() + + val slicedQuestion: MutableList = questionWord.toMutableList() + val slicedWord: MutableList = word.toMutableList() + + checkGreen(slicedWord, slicedQuestion, hint) + checkYellow(slicedWord, slicedQuestion, hint) + + return hint + } + + private fun checkYellow( + slicedWord: MutableList, + slicedQuestion: MutableList, + hint: MutableList, + ) { + slicedWord.forEachIndexed { index, word -> + if (word != BLANK && slicedQuestion.contains(word)) { + hint[index] = Hint.YELLOW + slicedQuestion[slicedQuestion.indexOf(word)] = BLANK + } + } + } + + private fun checkGreen( + slicedWord: MutableList, + slicedQuestion: MutableList, + hint: MutableList, + ) { + slicedWord.forEachIndexed { index, _ -> + if (slicedQuestion[index] == slicedWord[index]) { + hint[index] = Hint.GREEN + slicedQuestion[index] = BLANK + slicedWord[index] = BLANK + } + } + } + + companion object { + private const val NO_SUCH_WORD_MESSAGE = "word.txt λ‚΄μ˜ 단어λ₯Ό μ„ νƒν•΄μ£Όμ„Έμš”" + private const val BLANK = ' ' + } +} diff --git a/src/main/kotlin/domain/TodayWordDictionary.kt b/src/main/kotlin/domain/TodayWordDictionary.kt new file mode 100644 index 0000000..19450b4 --- /dev/null +++ b/src/main/kotlin/domain/TodayWordDictionary.kt @@ -0,0 +1,26 @@ +package domain + +import java.io.FileReader +import java.time.LocalDate +import java.time.temporal.ChronoUnit + +class TodayWordDictionary : WordDictionary { + + private val words: List = FileReader(WORD_RESOURCE).readText().split(DELIMITERS) + + override fun pickWord(): String { + val until = ChronoUnit.DAYS.between(TARGET_DATE, LocalDate.now()) + + return words[(until % words.size).toInt()] + } + + override fun contains(target: String): Boolean { + return words.contains(target) + } + + companion object { + private const val WORD_RESOURCE = "src/main/resources/words.txt" + private const val DELIMITERS = "\n" + private val TARGET_DATE = LocalDate.of(2021, 6, 19) + } +} diff --git a/src/main/kotlin/domain/TryCount.kt b/src/main/kotlin/domain/TryCount.kt new file mode 100644 index 0000000..bb8f36f --- /dev/null +++ b/src/main/kotlin/domain/TryCount.kt @@ -0,0 +1,24 @@ +package domain + +data class TryCount(val tryCount: Int = 0) { + + init { + validatePositive() + } + + private fun validatePositive() { + require(tryCount >= 0) { ERROR_MESSAGE } + } + + fun plus(): TryCount { + return TryCount(tryCount.inc()) + } + + fun isSame(other: TryCount): Boolean { + return this.tryCount == other.tryCount + } + + companion object { + private const val ERROR_MESSAGE = "μ–‘μˆ˜λ₯Ό μž…λ ₯ν•΄μ£Όμ„Έμš”" + } +} diff --git a/src/main/kotlin/domain/WordDictionary.kt b/src/main/kotlin/domain/WordDictionary.kt new file mode 100644 index 0000000..f64946e --- /dev/null +++ b/src/main/kotlin/domain/WordDictionary.kt @@ -0,0 +1,8 @@ +package domain + +interface WordDictionary { + + fun pickWord(): String + + fun contains(target: String): Boolean +} diff --git a/src/main/kotlin/view/InputView.kt b/src/main/kotlin/view/InputView.kt new file mode 100644 index 0000000..a6d7b65 --- /dev/null +++ b/src/main/kotlin/view/InputView.kt @@ -0,0 +1,9 @@ +package view + +class InputView { + + fun readAnswer(): String { + println("정닡을 μž…λ ₯ν•΄ μ£Όμ„Έμš”.") + return readlnOrNull() ?: throw IllegalArgumentException("정닡을 μž…λ ₯ν•΄ μ£Όμ„Έμš”.") + } +} diff --git a/src/main/kotlin/view/OutputView.kt b/src/main/kotlin/view/OutputView.kt new file mode 100644 index 0000000..3802c35 --- /dev/null +++ b/src/main/kotlin/view/OutputView.kt @@ -0,0 +1,28 @@ +package view + +import domain.Hint +import view.utils.HintEmojiMapper + +class OutputView { + + fun printWelcomeMessage() { + println( + "WORDLE을 6번 λ§Œμ— 맞좰 λ³΄μ„Έμš”.\n" + + "μ‹œλ„μ˜ κ²°κ³ΌλŠ” νƒ€μΌμ˜ 색 λ³€ν™”λ‘œ λ‚˜νƒ€λ‚©λ‹ˆλ‹€.", + ) + } + + fun printTryCount(userTryCount: Int, maxTryCount: Int) { + println("$userTryCount / $maxTryCount") + } + + fun printHint(allHints: List>) { + for (hints in allHints) { + println(HintEmojiMapper.emojiMapping(hints)) + } + } + + fun printErrorMessage(errorMessage: String?) { + println(errorMessage) + } +} diff --git a/src/main/kotlin/view/utils/HintEmojiMapper.kt b/src/main/kotlin/view/utils/HintEmojiMapper.kt new file mode 100644 index 0000000..5949da1 --- /dev/null +++ b/src/main/kotlin/view/utils/HintEmojiMapper.kt @@ -0,0 +1,26 @@ +package view.utils + +import domain.Hint + +enum class HintEmojiMapper(val hintEmoji: String, val hint: Hint) { + + GRAY("⬜", Hint.GRAY), + YELLOW("🟨", Hint.YELLOW), + GREEN("🟩", Hint.GREEN), + ; + + companion object { + private const val CANNOT_FOUND_HINT_EMOJI_ERROR_MESSAGE = "ν•΄λ‹Ήν•˜λŠ” 힌트 이λͺ¨μ§€λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€." + + fun emojiMapping(hints: List): String { + return hints.joinToString("") { convertHintEmoji(it) } + } + + private fun convertHintEmoji(hint: Hint): String { + val emojiMapper = HintEmojiMapper.values().find { + it.hint == hint + } ?: throw IllegalArgumentException(CANNOT_FOUND_HINT_EMOJI_ERROR_MESSAGE) + return emojiMapper.hintEmoji + } + } +} diff --git a/src/test/kotlin/domain/FixedWordDictionary.kt b/src/test/kotlin/domain/FixedWordDictionary.kt new file mode 100644 index 0000000..723592d --- /dev/null +++ b/src/test/kotlin/domain/FixedWordDictionary.kt @@ -0,0 +1,12 @@ +package domain + +class FixedWordDictionary(val word: String) : WordDictionary { + + override fun pickWord(): String { + return word + } + + override fun contains(target: String): Boolean { + return true + } +} diff --git a/src/test/kotlin/domain/QuestionTest.kt b/src/test/kotlin/domain/QuestionTest.kt new file mode 100644 index 0000000..98cc67b --- /dev/null +++ b/src/test/kotlin/domain/QuestionTest.kt @@ -0,0 +1,86 @@ +package domain + +import domain.Hint.GRAY +import domain.Hint.GREEN +import domain.Hint.YELLOW +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.DisplayNameGeneration +import org.junit.jupiter.api.DisplayNameGenerator +import org.junit.jupiter.api.Test + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores::class) +class QuestionTest { + + @Test + fun νƒ€κΉƒνŒŒμΌμ—_μ—†λŠ”_단어λ₯Ό_쀄_μ‹œ_μ˜ˆμ™Έκ°€_λ°œμƒν•œλ‹€() { + // given + val word = "abcde" + val question = Question(TodayWordDictionary()) + + // expect + assertThrows(IllegalArgumentException::class.java) { question.isAnswer(word) } + } + + @Test + fun 정닡이라면_trueλ₯Ό_λ°˜ν™˜ν•œλ‹€() { + // given + val word = "abcde" + val question = Question(FixedWordDictionary(word)) + + // expect + assertTrue(question.isAnswer(word)) + } + + @Test + fun 정닡이_μ•„λ‹ˆ_라면_falseλ₯Ό_λ°˜ν™˜ν•œλ‹€() { + // given + val word = "cigar" + val question = Question(FixedWordDictionary(word)) + + // expect + assertFalse(question.isAnswer("sissy")) + } + + @Test + fun 같은_μžλ¦¬μ—_같은_κΈ€μžμ΄λ©΄_ν•΄λ‹Ή_μžλ¦¬μ—_GREEN_힌트λ₯Ό_μ€€λ‹€() { + // given + val word = "cigar" + val question = Question(FixedWordDictionary(word)) + + // when + val hint: List = question.getHint("cbdef") + + // then + assertThat(hint).containsExactly(GREEN, GRAY, GRAY, GRAY, GRAY) + } + + @Test + fun λ‹€λ₯Έ_μžλ¦¬μ—_같은_κΈ€μžμ΄λ©΄_μž…λ ₯ν•œ_ν•΄λ‹Ή_μžλ¦¬μ—_YELLOW_힌트λ₯Ό_μ€€λ‹€() { + // given + val word = "cigar" + val question = Question(FixedWordDictionary(word)) + + // when + val hint: List = question.getHint("bgbgi") + + // then + assertThat(hint).containsExactly(GRAY, YELLOW, GRAY, GRAY, YELLOW) + } + + @Test + fun GREENκ³Ό_YELLOWκ°€_μ„žμΈ_경우() { + // given + val word = "spill" + val question = Question(FixedWordDictionary(word)) + + // when + val hint: List = question.getHint("hello") + + // then + assertThat(hint).containsExactly(GRAY, GRAY, YELLOW, GREEN, GRAY) + } +} diff --git a/src/test/kotlin/domain/TryCountTest.kt b/src/test/kotlin/domain/TryCountTest.kt new file mode 100644 index 0000000..99eb4ee --- /dev/null +++ b/src/test/kotlin/domain/TryCountTest.kt @@ -0,0 +1,54 @@ +package domain + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.DisplayNameGeneration +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow + +@SuppressWarnings("NonAsciiCharacters") +@DisplayNameGeneration(ReplaceUnderscores::class) +class TryCountTest { + + @Test + fun μ‹œλ„νšŒμˆ˜λŠ”_μ–‘μˆ˜λ₯Ό_λ°›μ•„_μƒμ„±λœλ‹€() { + // given + val count: Int = 10 + + // expect + assertDoesNotThrow { TryCount(count) } + } + + @Test + fun μ‹œλ„νšŒμˆ˜λŠ”_음수λ₯Ό_λ°›μœΌλ©΄_μ˜ˆμ™Έκ°€_λ°œμƒν•œλ‹€() { + // given + val count: Int = -1 + + // expect + assertThrows(IllegalArgumentException::class.java) { TryCount(count) } + } + + @Test + fun μ‹œλ„νšŒμˆ˜λŠ”_올라갈_수_μžˆλ‹€() { + // given + val tryCount1 = TryCount() + val tryCount2 = TryCount(1) + + // when + val tryCount3: TryCount = tryCount1.plus() + + // then + assertEquals(tryCount2, tryCount3) + } + + @Test + fun νšŒμˆ˜κ°€_κ°™λ‹€λ©΄_trueλ₯Ό_λ°˜ν™˜ν•œλ‹€() { + // given + val tryCount1 = TryCount(1) + val tryCount2 = TryCount(1) + + // when + assertEquals(true, tryCount1.isSame(tryCount2)) + } +}