From a64e56dbcb4d17447997eb0f0d00abf80bb1b6d4 Mon Sep 17 00:00:00 2001 From: Marcin Date: Sat, 28 Dec 2024 18:07:10 +0100 Subject: [PATCH] Add support for single line format parser --- README.md | 12 +- .../io/github/ilikeyourhat/kudoku/Kudoku.kt | 7 +- .../kudoku/parsing/EmptyFieldIndicator.kt | 10 ++ .../kudoku/parsing/SingleLineSudokuParser.kt | 72 ++++++++ .../ilikeyourhat/kudoku/parsing/SudokuExt.kt | 7 + .../{text => }/SudokuTextFormatParser.kt | 2 +- .../parsing/SingleLineFormatTest.kt | 48 ++++++ .../parsing/SingleLineSudokuParserTest.kt | 158 ++++++++++++++++++ .../parsing/SudokuTextFormatParserTest.kt | 1 - 9 files changed, 312 insertions(+), 5 deletions(-) create mode 100644 src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/EmptyFieldIndicator.kt create mode 100644 src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SingleLineSudokuParser.kt create mode 100644 src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuExt.kt rename src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/{text => }/SudokuTextFormatParser.kt (98%) create mode 100644 src/test/kotlin/io/github/ilikeyourhat/kudoku/integration/parsing/SingleLineFormatTest.kt create mode 100644 src/test/kotlin/io/github/ilikeyourhat/kudoku/parsing/SingleLineSudokuParserTest.kt diff --git a/README.md b/README.md index 5bbd14a..abf365b 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ Create a solver instance and solve the board: ```kotlin val solver = Kudoku.defaultSolver() val solution = solver.solve(sudoku) -println(solution.toString()) +println(solution) ``` Choose from multiple solver implementations: @@ -72,11 +72,19 @@ val solver1 = Kudoku.satSolver() val solver2 = Kudoku.bruteForceSolver() ``` +Support for popular text formats: + +```kotlin +val string = "003020600900305001001806400008102900700000008006708200002609500800203009005010300" +val sudoku = Kudoku.createFromSingleLineString(string) +val encoded = sudoku.toSingleLineString(emptyFieldIndicator = EmptyFieldIndicator.DOT) +``` + Create a random Sudoku with a given difficulty: ```kotlin val sudoku = Kudoku.create(SudokuType.Classic9x9, Difficulty.VERY_HARD) -println(sudoku.toString()) +println(sudoku) ``` Check how hard is a given sudoku: diff --git a/src/main/kotlin/io/github/ilikeyourhat/kudoku/Kudoku.kt b/src/main/kotlin/io/github/ilikeyourhat/kudoku/Kudoku.kt index 89c61f4..823c5b6 100644 --- a/src/main/kotlin/io/github/ilikeyourhat/kudoku/Kudoku.kt +++ b/src/main/kotlin/io/github/ilikeyourhat/kudoku/Kudoku.kt @@ -3,7 +3,8 @@ package io.github.ilikeyourhat.kudoku import io.github.ilikeyourhat.kudoku.generating.SudokuGenerator import io.github.ilikeyourhat.kudoku.model.Sudoku import io.github.ilikeyourhat.kudoku.model.SudokuType -import io.github.ilikeyourhat.kudoku.parsing.text.SudokuTextFormatParser +import io.github.ilikeyourhat.kudoku.parsing.SingleLineSudokuParser +import io.github.ilikeyourhat.kudoku.parsing.SudokuTextFormatParser import io.github.ilikeyourhat.kudoku.rating.DeductionBasedRater import io.github.ilikeyourhat.kudoku.rating.Difficulty import io.github.ilikeyourhat.kudoku.solving.SolutionCount @@ -46,6 +47,10 @@ object Kudoku { return SudokuTextFormatParser(supportedTypes).parseOne(string) } + fun createFromSingleLineString(string: String): Sudoku { + return SingleLineSudokuParser().fromText(string) + } + fun rate(sudoku: Sudoku): Difficulty { return DeductionBasedRater().rate(sudoku) } diff --git a/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/EmptyFieldIndicator.kt b/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/EmptyFieldIndicator.kt new file mode 100644 index 0000000..9601b28 --- /dev/null +++ b/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/EmptyFieldIndicator.kt @@ -0,0 +1,10 @@ +package io.github.ilikeyourhat.kudoku.parsing + +enum class EmptyFieldIndicator(val value: Char) { + ZERO('0'), + DOT('.'), + X('X'), + ASTERISK('*'), + UNDERSCORE('_'), + SPACE(' ') +} diff --git a/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SingleLineSudokuParser.kt b/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SingleLineSudokuParser.kt new file mode 100644 index 0000000..97f8372 --- /dev/null +++ b/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SingleLineSudokuParser.kt @@ -0,0 +1,72 @@ +package io.github.ilikeyourhat.kudoku.parsing + +import io.github.ilikeyourhat.kudoku.model.Sudoku +import io.github.ilikeyourhat.kudoku.type.Classic12x12 +import io.github.ilikeyourhat.kudoku.type.Classic16x16 +import io.github.ilikeyourhat.kudoku.type.Classic25x25 +import io.github.ilikeyourhat.kudoku.type.Classic4x4 +import io.github.ilikeyourhat.kudoku.type.Classic6x6 +import io.github.ilikeyourhat.kudoku.type.Classic9x9 + +@Suppress("MagicNumber") +class SingleLineSudokuParser { + + private val typeMap = mapOf( + 16 to Classic4x4, + 36 to Classic6x6, + 81 to Classic9x9, + 144 to Classic12x12, + 256 to Classic16x16, + 625 to Classic25x25 + ) + + fun toText( + sudoku: Sudoku, + emptyFieldIndicator: EmptyFieldIndicator + ): String { + require(sudoku.isSupported()) { "Unsupported sudoku type: ${sudoku.type.name}" } + + return sudoku.allFields + .map { encodeValue(it.value, emptyFieldIndicator) } + .joinToString("") + } + + fun fromText(text: String): Sudoku { + val type = typeMap[text.length] + ?: throw IllegalArgumentException("Unsupported sudoku type with input length ${text.length}") + + val values = text + .map { decodeValue(it) } + .toList() + return Sudoku(type, values) + } + + private fun Sudoku.isSupported(): Boolean { + return typeMap.containsValue(type) + } + + private fun encodeValue(value: Int, emptyFieldIndicator: EmptyFieldIndicator): Char { + return when (value) { + 0 -> emptyFieldIndicator.value + in 1..9 -> value.digitToChar() + in 10..25 -> 'A'.plus(value - 10) + else -> throw IllegalArgumentException("Value $value is not supported") + } + } + + private fun decodeValue(value: Char): Int { + return when (value) { + in '1'..'9' -> value.digitToInt() + in 'A'..LAST_SUPPORTED_LETTER_UPPERCASE -> value.code - 'A'.code + 10 + in 'a'..LAST_SUPPORTED_LETTER_LOWERCASE -> value.code - 'a'.code + 10 + in EMPTY_FIELD_INDICATORS -> 0 + else -> throw IllegalArgumentException("Value $value is not supported") + } + } + + private companion object { + const val LAST_SUPPORTED_LETTER_UPPERCASE = 'P' + const val LAST_SUPPORTED_LETTER_LOWERCASE = 'p' + val EMPTY_FIELD_INDICATORS = EmptyFieldIndicator.entries.map { it.value } + } +} diff --git a/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuExt.kt b/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuExt.kt new file mode 100644 index 0000000..710815f --- /dev/null +++ b/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuExt.kt @@ -0,0 +1,7 @@ +package io.github.ilikeyourhat.kudoku.parsing + +import io.github.ilikeyourhat.kudoku.model.Sudoku + +fun Sudoku.toSingleLineString(emptyFieldIndicator: EmptyFieldIndicator = EmptyFieldIndicator.ZERO): String { + return SingleLineSudokuParser().toText(this, emptyFieldIndicator) +} diff --git a/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/text/SudokuTextFormatParser.kt b/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuTextFormatParser.kt similarity index 98% rename from src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/text/SudokuTextFormatParser.kt rename to src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuTextFormatParser.kt index 4511399..e095c53 100644 --- a/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/text/SudokuTextFormatParser.kt +++ b/src/main/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuTextFormatParser.kt @@ -1,4 +1,4 @@ -package io.github.ilikeyourhat.kudoku.parsing.text +package io.github.ilikeyourhat.kudoku.parsing import io.github.ilikeyourhat.kudoku.model.Sudoku import io.github.ilikeyourhat.kudoku.model.SudokuType diff --git a/src/test/kotlin/io/github/ilikeyourhat/kudoku/integration/parsing/SingleLineFormatTest.kt b/src/test/kotlin/io/github/ilikeyourhat/kudoku/integration/parsing/SingleLineFormatTest.kt new file mode 100644 index 0000000..3a892c8 --- /dev/null +++ b/src/test/kotlin/io/github/ilikeyourhat/kudoku/integration/parsing/SingleLineFormatTest.kt @@ -0,0 +1,48 @@ +package io.github.ilikeyourhat.kudoku.integration.parsing + +import io.github.ilikeyourhat.kudoku.Kudoku +import io.github.ilikeyourhat.kudoku.model.Sudoku +import io.github.ilikeyourhat.kudoku.parsing.EmptyFieldIndicator +import io.github.ilikeyourhat.kudoku.parsing.toSingleLineString +import io.github.ilikeyourhat.kudoku.type.Classic4x4 +import io.kotest.matchers.equals.shouldBeEqual +import org.junit.jupiter.api.Test + +class SingleLineFormatTest { + + @Test + fun `should handle simple encoding`() { + val sudoku = Sudoku( + Classic4x4, + listOf( + 0, 0, 1, 0, + 3, 0, 0, 0, + 0, 4, 0, 0, + 1, 0, 4, 0 + ) + ) + + sudoku.toSingleLineString() + .shouldBeEqual("0010300004001040") + sudoku.toSingleLineString(emptyFieldIndicator = EmptyFieldIndicator.DOT) + .shouldBeEqual("..1.3....4..1.4.") + } + + @Test + fun `should handle simple decoding`() { + val expectedSudoku = Sudoku( + Classic4x4, + listOf( + 0, 0, 1, 0, + 3, 0, 0, 0, + 0, 4, 0, 0, + 1, 0, 4, 0 + ) + ) + + Kudoku.createFromSingleLineString("0010300004001040") + .shouldBeEqual(expectedSudoku) + Kudoku.createFromSingleLineString("..1.3....4..1.4.") + .shouldBeEqual(expectedSudoku) + } +} diff --git a/src/test/kotlin/io/github/ilikeyourhat/kudoku/parsing/SingleLineSudokuParserTest.kt b/src/test/kotlin/io/github/ilikeyourhat/kudoku/parsing/SingleLineSudokuParserTest.kt new file mode 100644 index 0000000..ba36dc4 --- /dev/null +++ b/src/test/kotlin/io/github/ilikeyourhat/kudoku/parsing/SingleLineSudokuParserTest.kt @@ -0,0 +1,158 @@ +package io.github.ilikeyourhat.kudoku.parsing + +import io.github.ilikeyourhat.kudoku.model.Sudoku +import io.github.ilikeyourhat.kudoku.type.BUILD_IN_TYPES +import io.github.ilikeyourhat.kudoku.type.Classic4x4 +import io.github.ilikeyourhat.kudoku.type.SamuraiClassic21x21 +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.booleans.shouldBeTrue +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.equals.shouldBeEqual +import io.kotest.matchers.throwable.shouldHaveMessage +import org.junit.jupiter.api.Test +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.CsvSource +import org.junit.jupiter.params.provider.ValueSource + +class SingleLineSudokuParserTest { + + private val parser = SingleLineSudokuParser() + + @ParameterizedTest + @ValueSource( + strings = [ + "0010300004001040", + "..1.3....4..1.4.", + "XX1X3XXXX4XX1X4X", + "**1*3****4**1*4*", + "__1_3____4__1_4_", + " 1 3 4 1 4 " + ] + ) + fun `should handle multiple empty field indicators`(encodedSudoku: String) { + val sudoku = Sudoku( + Classic4x4, + listOf( + 0, 0, 1, 0, + 3, 0, 0, 0, + 0, 4, 0, 0, + 1, 0, 4, 0 + ) + ) + + parser.fromText(encodedSudoku) + .shouldBeEqual(sudoku) + } + + @ParameterizedTest + @CsvSource( + value = [ + "classic_4x4, 1234", + "classic_6x6, 123456", + "classic_9x9, 123456789", + "classic_12x12, 123456789ABC", + "classic_12x12, 123456789abc", + "classic_16x16, 123456789ABCDEFG", + "classic_16x16, 123456789abcdefg", + "classic_25x25, 123456789ABCDEFGHIJKLMNOP", + "classic_25x25, 123456789abcdefghijklmnop" + ] + ) + fun `should decode different sudoku types`(type: String, possibleValues: String) { + val encodedSudoku = possibleValues.repeat(possibleValues.length) + + val sudoku = parser.fromText(encodedSudoku) + + sudoku.type.name + .shouldBeEqual(type) + sudoku.isCompleted() + .shouldBeTrue() + sudoku.values() + .distinct() + .shouldHaveSize(possibleValues.length) + } + + @Test + fun `should throw exception when decoding wrong length`() { + val encodedSudoku = "1234123412" + + shouldThrow { + parser.fromText(encodedSudoku) + }.shouldHaveMessage("Unsupported sudoku type with input length 10") + } + + @Test + fun `should throw exception when decoding unsupported value`() { + val encodedSudoku = "..1.&....4..1.4." + + shouldThrow { + parser.fromText(encodedSudoku) + }.shouldHaveMessage("Value & is not supported") + } + + @ParameterizedTest + @CsvSource( + value = [ + "ZERO|0010300004001040", + "DOT|..1.3....4..1.4.", + "X|XX1X3XXXX4XX1X4X", + "ASTERISK|**1*3****4**1*4*", + "UNDERSCORE|__1_3____4__1_4_", + "SPACE| 1 3 4 1 4 " + ], + delimiter = '|', + ignoreLeadingAndTrailingWhitespace = false + ) + fun `should handle multiple empty field indicators`( + emptyFieldIndicator: EmptyFieldIndicator, + expectedString: String + ) { + val sudoku = Sudoku( + Classic4x4, + listOf( + 0, 0, 1, 0, + 3, 0, 0, 0, + 0, 4, 0, 0, + 1, 0, 4, 0 + ) + ) + + parser.toText(sudoku, emptyFieldIndicator) + .shouldBeEqual(expectedString) + } + + @ParameterizedTest + @ValueSource( + strings = [ + "classic_4x4", + "classic_6x6", + "classic_9x9", + "classic_12x12", + "classic_16x16", + "classic_25x25" + ] + ) + fun `should encode different sudoku types`(typeName: String) { + val type = BUILD_IN_TYPES.single { it.name == typeName } + val values = (1..type.maxValue) + .flatMap { (1..type.maxValue) } + val sudoku = Sudoku(type, values) + + val possibleValues = "123456789ABCDEFGHIJKLMNOP" + .slice(0 until type.maxValue) + + val encodedSudoku = parser.toText(sudoku, EmptyFieldIndicator.ZERO) + + encodedSudoku + .shouldBeEqual(possibleValues.repeat(possibleValues.length)) + } + + @Test + fun `should throw exception when encoding unsupported type`() { + val sudoku = Sudoku(SamuraiClassic21x21) + + shouldThrow { + parser.toText(sudoku, EmptyFieldIndicator.ZERO) + }.shouldHaveMessage("Unsupported sudoku type: samurai_classic_21x21") + } +} diff --git a/src/test/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuTextFormatParserTest.kt b/src/test/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuTextFormatParserTest.kt index 5808a22..72f9343 100644 --- a/src/test/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuTextFormatParserTest.kt +++ b/src/test/kotlin/io/github/ilikeyourhat/kudoku/parsing/SudokuTextFormatParserTest.kt @@ -1,7 +1,6 @@ package io.github.ilikeyourhat.kudoku.parsing import io.github.ilikeyourhat.kudoku.model.Sudoku -import io.github.ilikeyourhat.kudoku.parsing.text.SudokuTextFormatParser import io.github.ilikeyourhat.kudoku.type.Classic9x9 import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test