Skip to content

Commit

Permalink
Merge pull request #63 from ILikeYourHat/single-line-format-parser
Browse files Browse the repository at this point in the history
Add support for single line format parser
  • Loading branch information
ILikeYourHat authored Dec 28, 2024
2 parents f5cf700 + a64e56d commit 1d2820e
Show file tree
Hide file tree
Showing 9 changed files with 312 additions and 5 deletions.
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
7 changes: 6 additions & 1 deletion src/main/kotlin/io/github/ilikeyourhat/kudoku/Kudoku.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package io.github.ilikeyourhat.kudoku.parsing

enum class EmptyFieldIndicator(val value: Char) {
ZERO('0'),
DOT('.'),
X('X'),
ASTERISK('*'),
UNDERSCORE('_'),
SPACE(' ')
}
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> {
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<IllegalArgumentException> {
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<IllegalArgumentException> {
parser.toText(sudoku, EmptyFieldIndicator.ZERO)
}.shouldHaveMessage("Unsupported sudoku type: samurai_classic_21x21")
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down

0 comments on commit 1d2820e

Please sign in to comment.