Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for single line format parser #63

Merged
merged 1 commit into from
Dec 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading