Refactor Sudoku field and solver implementation

This commit is contained in:
2026-09-16 22:52:44 +02:00
parent 8a5ae8c640
commit f7f9c16184
24 changed files with 1574 additions and 603 deletions
+90 -14
View File
@@ -1,30 +1,106 @@
package parser
import "git.kleiax.de/homepage/field"
import (
"errors"
"fmt"
"slices"
"strings"
//TODO: const für klassik sudoku board.Props
//TODO: funktion für [][]Cell generierung
"git.kleiax.de/homepage/field"
)
var (
ErrFieldIndex = errors.New("field index out of range")
ErrInvalidInput = errors.New("invalid puzzle input")
ErrNoPuzzles = errors.New("input contains no puzzles")
)
func ClassicProperties() field.Properties {
return field.Properties{
Rows: 9,
Columns: 9,
BlockRows: 3,
BlockColumns: 3,
BlockSizeRow: 3,
BlockSizeColumn: 3,
}
}
type Parser interface {
Parse(data []byte) error
GetField(i int) *field.Field
GetAllFields() []field.Field
Parse([]byte) error
GetField(int) (*field.Field, error)
GetAllFields() []*field.Field
}
type ParserHelper struct {
fields []field.Field
fields []*field.Field
}
func (ph *ParserHelper) GetField(i int) *field.Field {
if i < 0 || i >= len(ph.fields) {
return &field.Field{}
func (ph *ParserHelper) GetField(index int) (*field.Field, error) {
if ph == nil || index < 0 || index >= len(ph.fields) {
return nil, fmt.Errorf("%w: %d", ErrFieldIndex, index)
}
return &ph.fields[i]
return ph.fields[index], nil
}
func (ph *ParserHelper) GetAllFields() []field.Field {
if len(ph.fields) == 0 {
func (ph *ParserHelper) GetAllFields() []*field.Field {
if ph == nil {
return nil
}
return ph.fields
return slices.Clone(ph.fields)
}
func classicField(digits string, rating float64) (*field.Field, error) {
props := ClassicProperties()
if len(digits) != props.Rows*props.Columns {
return nil, fmt.Errorf("%w: puzzle has %d characters, expected 81", ErrInvalidInput, len(digits))
}
props.Rating = rating
cells := make([][]field.Cell, props.Rows)
for row := 0; row < props.Rows; row++ {
cells[row] = make([]field.Cell, props.Columns)
for column := 0; column < props.Columns; column++ {
char := digits[row*props.Columns+column]
if char < '0' || char > '9' {
return nil, fmt.Errorf("%w: puzzle contains invalid character %q at position %d", ErrInvalidInput, char, row*props.Columns+column)
}
position := field.NewPosition(
row,
column,
row/props.BlockSizeRow,
column/props.BlockSizeColumn,
row%props.BlockSizeRow,
column%props.BlockSizeColumn,
)
cells[row][column] = *field.NewCell(int(char-'0'), position)
}
}
result, err := field.New(props, cells)
if err != nil {
return nil, fmt.Errorf("%w: %w", ErrInvalidInput, err)
}
return result, nil
}
// PuzzleString parses one classic Sudoku represented by exactly 81 digits.
// Whitespace around the complete string is ignored; 0 denotes an empty cell.
type PuzzleString struct {
ParserHelper
}
func (p *PuzzleString) Parse(data []byte) error {
if p == nil {
return errors.New("nil puzzle string parser")
}
digits := strings.TrimSpace(string(data))
if digits == "" {
return ErrNoPuzzles
}
parsed, err := classicField(digits, 0)
if err != nil {
return fmt.Errorf("parse puzzle string: %w", err)
}
p.fields = []*field.Field{parsed}
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package parser
import (
"crypto/sha1"
"errors"
"fmt"
"strings"
"testing"
)
const validSolvedPuzzle = "123456789456789123789123456234567891567891234891234567345678912678912345912345678"
func bankRecord(digits string, rating float64) string {
hash := fmt.Sprintf("%x", sha1.Sum([]byte(digits)))[:12]
return fmt.Sprintf("%s %s %.1f", hash, digits, rating)
}
func TestPuzzleBankParsesLFCRLFAndFinalLine(t *testing.T) {
first := "0" + validSolvedPuzzle[1:]
data := bankRecord(first, 1.2) + "\r\n\n" + bankRecord(validSolvedPuzzle, 2.5)
parser := &PuzzleBank{}
if err := parser.Parse([]byte(data)); err != nil {
t.Fatalf("Parse() error = %v", err)
}
if got := len(parser.GetAllFields()); got != 2 {
t.Fatalf("field count = %d, want 2", got)
}
second, err := parser.GetField(1)
if err != nil {
t.Fatal(err)
}
if second.GetRating() != 2.5 || !second.IsSolved() {
t.Fatalf("second field: rating=%v solved=%v", second.GetRating(), second.IsSolved())
}
}
func TestPuzzleBankRejectsMalformedInput(t *testing.T) {
valid := bankRecord(validSolvedPuzzle, 1.2)
tests := map[string]string{
"empty": "\n\r\n",
"columns": "broken",
"hash length": "abc " + validSolvedPuzzle + " 1.2",
"hash mismatch": "ffffffffffff" + valid[12:],
"bad puzzle": bankRecord("x"+validSolvedPuzzle[1:], 1.2),
"bad rating": strings.TrimSuffix(valid, "1.2") + "nope",
}
for name, input := range tests {
t.Run(name, func(t *testing.T) {
if err := (&PuzzleBank{}).Parse([]byte(input)); err == nil || (name != "empty" && !errors.Is(err, ErrInvalidInput)) {
t.Fatalf("Parse() error = %v", err)
}
})
}
}
func TestPuzzleBankParseIsAtomicAndDoesNotAppend(t *testing.T) {
parser := &PuzzleBank{}
valid := bankRecord(validSolvedPuzzle, 1.2)
if err := parser.Parse([]byte(valid)); err != nil {
t.Fatal(err)
}
original, _ := parser.GetField(0)
if err := parser.Parse([]byte(valid + "\ninvalid")); err == nil {
t.Fatal("invalid Parse() error = nil")
}
afterFailure, _ := parser.GetField(0)
if afterFailure != original || len(parser.GetAllFields()) != 1 {
t.Fatal("failed parse changed previously parsed fields")
}
if err := parser.Parse([]byte(valid)); err != nil {
t.Fatal(err)
}
if len(parser.GetAllFields()) != 1 {
t.Fatal("repeated Parse() appended fields")
}
}
func TestParserHelperBoundsAndDefensiveSlice(t *testing.T) {
parser := &PuzzleString{}
if err := parser.Parse([]byte(validSolvedPuzzle)); err != nil {
t.Fatal(err)
}
for _, index := range []int{-1, 1} {
if _, err := parser.GetField(index); !errors.Is(err, ErrFieldIndex) {
t.Fatalf("GetField(%d) error = %v", index, err)
}
}
fields := parser.GetAllFields()
fields[0] = nil
if stored, err := parser.GetField(0); err != nil || stored == nil {
t.Fatal("GetAllFields exposed its backing slice")
}
}
func TestPuzzleStringValidatesInput(t *testing.T) {
parser := &PuzzleString{}
if err := parser.Parse([]byte(" \n" + validSolvedPuzzle + "\n")); err != nil {
t.Fatalf("Parse() error = %v", err)
}
if fields := parser.GetAllFields(); len(fields) != 1 || !fields[0].IsSolved() {
t.Fatal("valid puzzle string was not parsed")
}
invalid := []string{"", validSolvedPuzzle[:80], "x" + validSolvedPuzzle[1:], "11" + validSolvedPuzzle[2:]}
for _, input := range invalid {
if err := (&PuzzleString{}).Parse([]byte(input)); err == nil {
t.Fatalf("Parse(%q) error = nil", input)
}
}
}
+37 -44
View File
@@ -2,73 +2,66 @@ package parser
import (
"bytes"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"math"
"strconv"
"strings"
"git.kleiax.de/homepage/field"
)
// https://github.com/grantm/sudoku-exchange-puzzle-bank/tree/master
type PuzzleBank struct {
ParserHelper
}
func (pb *PuzzleBank) Parse(data []byte) error {
if pb == nil {
return errors.New("nil puzzle bank parser")
}
lines := bytes.Split(data, []byte("\n"))
for _, line := range lines {
if len(line) == 0 { // Überspringe leere Zeile
var parsed []*field.Field
for index, rawLine := range bytes.Split(data, []byte("\n")) {
lineNumber := index + 1
line := strings.TrimSpace(string(rawLine))
if line == "" {
continue
}
if len(line) != 99 {
return fmt.Errorf("Zeile hat die falsche länge. soll: 100, ist: %d", len(line))
columns := strings.Fields(line)
if len(columns) != 3 {
return fmt.Errorf("line %d: %w: expected hash, puzzle and rating", lineNumber, ErrInvalidInput)
}
if len(columns[0]) != 12 {
return fmt.Errorf("line %d: %w: hash has %d characters, expected 12", lineNumber, ErrInvalidInput, len(columns[0]))
}
if _, err := hex.DecodeString(columns[0]); err != nil {
return fmt.Errorf("line %d: %w: invalid hash: %v", lineNumber, ErrInvalidInput, err)
}
digest := sha1.Sum([]byte(columns[1]))
if columns[0] != hex.EncodeToString(digest[:])[:12] {
return fmt.Errorf("line %d: %w: hash does not match puzzle", lineNumber, ErrInvalidInput)
}
//siehe Readme in github repo
//sha1Hash := bytes.TrimSpace(line[0:12]) // Wird aktuell nicht gebraucht, später zum sudoku vergleichen
sudokuStr := string(line[13:94])
ratingStr := string(line[96:99])
//fmt.Println(sudokuStr)
var rating float64
_, err := fmt.Sscanf(ratingStr, "%f", &rating)
rating, err := strconv.ParseFloat(columns[2], 64)
if err != nil {
return errors.New("can nor parse raiting")
return fmt.Errorf("line %d: %w: invalid rating %q: %v", lineNumber, ErrInvalidInput, columns[2], err)
}
props := field.Properties{
Rows: 9,
Columns: 9,
BlockRows: 3,
BlockColumns: 3,
BlockSizeRow: 3,
BlockSizeColumn: 3,
Rating: rating,
if rating < 0 || math.IsNaN(rating) || math.IsInf(rating, 0) {
return fmt.Errorf("line %d: %w: rating must be a finite non-negative number", lineNumber, ErrInvalidInput)
}
cells := make([][]field.Cell, props.Rows)
for i := range cells {
cells[i] = make([]field.Cell, props.Columns)
for j := range cells[i] {
number, err := strconv.Atoi(string(sudokuStr[i*9+j]))
if err != nil {
return fmt.Errorf("Kann Zeichen nicht in Zahl konvertieren")
}
pos := field.NewPosition(
i,
j,
i/props.BlockRows,
j/props.BlockColumns,
i%props.BlockRows,
j%props.BlockColumns)
cells[i][j] = *field.NewCell(number, pos)
}
parsedField, err := classicField(columns[1], rating)
if err != nil {
return fmt.Errorf("line %d: %w", lineNumber, err)
}
pb.fields = append(pb.fields, *field.New(props, cells))
parsed = append(parsed, parsedField)
}
if len(parsed) == 0 {
return ErrNoPuzzles
}
pb.fields = parsed
return nil
}