diff --git a/README.md b/README.md index a01fc41..dfa9db5 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ menschlichen Lösungsstrategien bearbeiten soll. Neben dem gelösten Feld soll langfristig auch der Lösungsweg mit Kandidaten, Änderungen und Markierungen verfügbar sein. -> [!WARNING] -> Das Projekt befindet sich in einer frühen Entwicklungsphase. Der aktuelle -> Prüflauf ist wegen eines Vet-Fehlers noch nicht grün und die Demo kann während des -> Lösens abstürzen. Siehe [Bekannte Einschränkungen](#bekannte-einschränkungen). +> [!NOTE] +> Das Projekt befindet sich weiterhin in einer frühen Entwicklungsphase. Die +> Basis ist getestet und die Demo läuft, der menschliche Solver unterstützt aber +> bisher nur vier grundlegende Strategien. ## Voraussetzungen @@ -43,8 +43,9 @@ gofmt -w main.go field/*.go parser/*.go logic/*.go logic/strategies/*.go sudoku/ go vet ./... ``` -Aktuell gibt es noch keine automatisierten Tests. Neue Funktionalität sollte -nach Möglichkeit mit paketnahen `*_test.go`-Tests ergänzt werden. +Die Pakete `field`, `parser`, `logic`, `logic/strategies` und `sudoku` besitzen +Unit- und Regressionstests. Neue Funktionalität sollte weiterhin mit paketnahen, +vorzugsweise tabellengesteuerten `*_test.go`-Tests ergänzt werden. ## Architektur @@ -70,8 +71,9 @@ Puzzle-Bank-Datei -> parser.PuzzleBank -> field.Field Eine Strategie implementiert `strategies.Strategy`. `SearchProgressableCells` sammelt mögliche Änderungen, `ApplyAll` oder `ApplyNext` übernimmt sie in das -Feld. Wird eine Zahl gesetzt, entfernt der Solver den entsprechenden Kandidaten -aus der zugehörigen Zeile, Spalte und dem Block. +Feld. Wird eine Zahl gesetzt, entfernt das Feldmodell den entsprechenden +Kandidaten automatisch und ohne Duplikate aus der zugehörigen Zeile, Spalte und +dem Block. Derzeit in `main.go` aktiv: @@ -88,25 +90,27 @@ implementiert. `parser.PuzzleBank` erwartet das Format der [Sudoku Exchange Puzzle Bank](data/sudoku-exchange-puzzle-bank/README.md): pro Zeile einen 12-stelligen Hash, 81 Ziffern für das Rätsel und eine -Schwierigkeitsbewertung. `0` steht für ein leeres Feld. Leere Zeilen werden -übersprungen; alle eingelesenen Rätsel werden im Parser gespeichert. +Schwierigkeitsbewertung. Hash, Zeichenzahl, Ziffern, Rating und Ausgangsbelegung +werden geprüft. `0` steht für ein leeres Feld. LF und CRLF werden unterstützt, +leere Zeilen werden übersprungen. + +Für einzelne Rätsel steht außerdem `parser.PuzzleString` zur Verfügung. Er +akzeptiert genau 81 Ziffern. Parser-Zugriffe liefern bei einem ungültigen Index +einen Fehler; bei mehreren Puzzle-Bank-Einträgen wird der Index explizit an +`sudoku.New` übergeben. Die Datensätze unter `data/sudoku-exchange-puzzle-bank/` stehen unter der dort beiliegenden separaten Lizenz. ## Bekannte Einschränkungen -- `field.Field.IsValid` und die `IsSolved`-Prüfungen einzelner Bereiche sind - noch nicht implementiert. Ein vollständig belegtes, aber ungültiges Feld kann - daher als gelöst gelten. - `logic.Solver.GetSolutionPath` liefert noch keinen Lösungsweg. - Mehrere fortgeschrittene Strategien sind nur als leere Typen vorhanden. -- `go test ./...` meldet aktuell in `field/field.go`, dass eine Ganzzahl direkt - in einen String konvertiert wird. -- Die Demo kann in `LastDigit.SearchProgressableCells` auf eine leere Zelle - zugreifen und dadurch abstürzen. -- Fehlertexte und Bezeichner sind derzeit teilweise deutsch, teilweise - englisch; einige öffentliche Namen enthalten noch Tippfehler. +- Der Solver besitzt noch kein gemeinsames Interface für menschliche, + Backtracking- und DLX-Implementierungen. +- Die Demo verwendet weiterhin einen fest kodierten Dateipfad und Puzzle-Index. +- Der alte öffentliche Bezeichner `InitStragies` bleibt vorerst als + Kompatibilitätsalias bestehen. ## Lizenz diff --git a/ROADMAP.md b/ROADMAP.md index 3224a63..e7bd0fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,7 +20,9 @@ Das erste stabile Release soll: Backtracking, DLX und sehr fortgeschrittene menschliche Strategien folgen auf dieses Kern-Release. -## Phase 0 – Lauffähige und getestete Basis (P0) +## Phase 0 – Lauffähige und getestete Basis (P0) ✅ + +Abgeschlossen am 10. September 2026. Zuerst wird der bestehende Zustand stabilisiert. In dieser Phase werden keine neuen Lösungsstrategien ergänzt. @@ -49,7 +51,9 @@ Abgeschlossen, wenn: - `go run .` das ausgewählte Beispiel ohne Panic beendet und - die bekannten Fehler jeweils einen Regressionstest besitzen. -## Phase 1 – Verlässliches Feldmodell (P0) +## Phase 1 – Verlässliches Feldmodell (P0) ✅ + +Abgeschlossen am 10. September 2026. - `field.Properties` beim Erzeugen prüfen: positive Dimensionen, passende Blockaufteilung und exakt passende Zellmatrix. @@ -65,14 +69,16 @@ Abgeschlossen, wenn: Fehler zurückgeben und das Feld unverändert lassen. - lesenden Zugriff auf Eigenschaften und Änderungshistorie anbieten, ohne interne Slices veränderbar nach außen zu geben. -- allgemeine und 9x9-spezifische Darstellung trennen; `StringNotesForNumber` - implementieren oder bis zu einem echten Bedarf aus der API entfernen. +- die fest codierte 9x9-Darstellung durch eine allgemeine Darstellung ersetzen + und `StringNotesForNumber` implementieren. Abgeschlossen, wenn gültige, ungültige, unvollständige und gelöste Felder in Tabellentests eindeutig unterschieden werden und keine Mutation die Feld-Invarianten umgehen kann. -## Phase 2 – Robuste Parser- und Game-API (P0) +## Phase 2 – Robuste Parser- und Game-API (P0) ✅ + +Abgeschlossen am 10. September 2026. - Parserfehler vereinheitlichen und mit Zeilennummer sowie fehlerhaftem Feld anreichern. @@ -95,17 +101,12 @@ und Parser sowie `Game` keine implizite Rätselauswahl mehr enthalten. - ein Solver-Interface definieren, das später menschlichen Solver, Backtracking und DLX austauschbar macht. -- `Run(int) bool` durch eine aussagekräftige Schritt-API ersetzen, zum Beispiel +- `Run(int) (bool, error)` durch eine aussagekräftige Schritt-API ersetzen, zum Beispiel mit den Zuständen `Progress`, `Solved`, `Stuck`, `Invalid` und `Failed`. -- Konfiguration aus den ungenutzten `conf`-Feldern ableiten oder diese entfernen. - Strategie-Reihenfolge, Wiederholungspunkt und `ApplyNext`/`ApplyAll` eindeutig definieren. -- gefundene Änderungen vor dem Anwenden deduplizieren und auf Konflikte prüfen. -- Kandidaten nach jedem Zahlenschritt korrekt und deterministisch aktualisieren. - Tippfehler in öffentlichen Namen (`InitStragies`, `TriggerdBy`) kontrolliert migrieren und alle Aufrufer anpassen. -- Abbruch bei Stillstand, ungültigem Zustand und internem Fehler sauber durch - `sudoku.Game.Solve` reichen. Abgeschlossen, wenn derselbe Input stets dieselben Schritte erzeugt und jeder Solverlauf genau einen überprüfbaren Endzustand besitzt. @@ -208,17 +209,16 @@ verwendet werden kann. Diese Tickets bilden die kürzeste Route zu einem stabilen Zwischenstand: -1. Test-Helfer und Feldzugriffs-Tests erstellen. -2. `ForEachCell`, `GetCell` und `Field.String` korrigieren. -3. Change-Aktionen sowie Nil- und Werteprüfung absichern. -4. `LastDigit` deduplizieren und den bekannten Panic per Regressionstest - beseitigen. -5. `HiddenSingle` korrigieren und vollständig testen. -6. `Part.IsSolved`, `Field.IsValid` und `Field.IsSolved` implementieren. -7. Puzzle-Bank-Parser atomar und indexsicher machen. -8. Solver-Schrittergebnis und Konflikterkennung einführen. -9. die vier Basisstrategien durch End-to-End-Rätseltests absichern. -10. erst danach Paar-/Tripel-Strategien oder neue Bedienfunktionen beginnen. +1. ein gemeinsames Solver-Interface und aussagekräftige Schrittzustände + entwerfen. +2. Strategie-Reihenfolge sowie `ApplyNext` und `ApplyAll` als öffentlichen + Vertrag festlegen und testen. +3. `InitStragies` und `TriggerdBy` kontrolliert auf korrekt geschriebene Namen + migrieren. +4. End-to-End-Tests für mehrere einfache und mittlere Puzzle-Bank-Rätsel + ergänzen. +5. anschließend `NakedPair`, `NakedTriple`, `HiddenPair` und `HiddenTriple` + implementieren. Nach jedem Arbeitspaket müssen `gofmt`, `go test ./...` und `go vet ./...` erfolgreich sein. Neue bekannte Baseline-Fehler sollen nicht angesammelt werden. diff --git a/field/cell.go b/field/cell.go index 9dc9c00..ae940cb 100644 --- a/field/cell.go +++ b/field/cell.go @@ -1,123 +1,77 @@ package field -// ────────────────────────────────────────────────────────────────────────────── // -// NOTES STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - import "slices" type Notes struct { numbers []int } -func (n *Notes) Add(field *Field, cell *Cell, note int, trigger string, marks []Mark) { - if slices.Contains(n.numbers, note) { - return // Note already exists +func (n *Notes) Add(f *Field, cell *Cell, note int, trigger string, marks []Mark) error { + if n == nil || cell == nil || cell.notes != n { + return ErrInvalidCell } - field.changes = append(field.changes, Change{ - Cell: cell, - marks: marks, - action: ActionSetNote, - value: note, - from: 0, - triggerdBy: trigger, - }) - n.numbers = append(n.numbers, note) + return f.AddChange(&ExternalChange{Cell: cell, Marks: marks, Action: ActionSetNote, Value: note, TriggerdBy: trigger}) } -func (n *Notes) Remove(field *Field, cell *Cell, note int, trigger string, marks []Mark) { - for i, existing := range n.numbers { - if existing == note { - field.changes = append(field.changes, Change{ - Cell: cell, - marks: marks, - action: ActionRemoveNote, - value: 0, - from: note, - triggerdBy: trigger, - }) - - n.numbers = append(n.numbers[:i], n.numbers[i+1:]...) - return - } +func (n *Notes) Remove(f *Field, cell *Cell, note int, trigger string, marks []Mark) error { + if n == nil || cell == nil || cell.notes != n { + return ErrInvalidCell } + return f.AddChange(&ExternalChange{Cell: cell, Marks: marks, Action: ActionRemoveNote, From: note, TriggerdBy: trigger}) } func (n *Notes) Get() []int { - copySlice := make([]int, len(n.numbers)) - copy(copySlice, n.numbers) - return copySlice + if n == nil { + return nil + } + return slices.Clone(n.numbers) } -func (n *Notes) Has(i int) bool { - return slices.Contains(n.numbers, i) +func (n *Notes) Has(number int) bool { + return n != nil && slices.Contains(n.numbers, number) } -// ────────────────────────────────────────────────────────────────────────────── // -// CELL STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Cell struct { number int - Notes *Notes - Pos *Position + notes *Notes + pos *Position } func NewCell(number int, pos *Position) *Cell { - cell := Cell{ - number: number, - Pos: pos, - Notes: &Notes{}, - } - return &cell + return &Cell{number: number, notes: &Notes{}, pos: pos} } -func (c *Cell) SetNumber(field *Field, n int, trigger string, marks []Mark) { - if c.number == n { - return +func (c *Cell) SetNumber(f *Field, number int, trigger string, marks []Mark) error { + if c == nil { + return ErrInvalidCell } - - if field == nil { - return - } - - if n <= 0 || n > field.props.Rows || n > field.props.Columns { - return - } - - field.changes = append(field.changes, Change{ - Cell: c, - marks: marks, - action: ActionSetNumber, - value: n, - from: c.number, - triggerdBy: trigger, - }) - - c.number = n + return f.AddChange(&ExternalChange{Cell: c, Marks: marks, Action: ActionSetNumber, Value: number, From: c.number, TriggerdBy: trigger}) } -func (c *Cell) RemoveNumber(field *Field, trigger string, marks []Mark) { - if c.number == 0 { - return +func (c *Cell) RemoveNumber(f *Field, trigger string, marks []Mark) error { + if c == nil { + return ErrInvalidCell } - - if field == nil { - return - } - - field.changes = append(field.changes, Change{ - action: ActionSetNumber, - Cell: c, - value: 0, - from: c.number, - marks: marks, - triggerdBy: trigger, - }) - - c.number = 0 + return f.AddChange(&ExternalChange{Cell: c, Marks: marks, Action: ActionRemoveNumber, From: c.number, TriggerdBy: trigger}) } func (c *Cell) GetNumber() int { + if c == nil { + return 0 + } return c.number } + +func (c *Cell) GetNotes() *Notes { + if c == nil { + return nil + } + return c.notes +} + +func (c *Cell) GetPosition() *Position { + if c == nil { + return nil + } + return c.pos +} diff --git a/field/changes.go b/field/changes.go index cb486b0..5d30f4f 100644 --- a/field/changes.go +++ b/field/changes.go @@ -1,13 +1,11 @@ package field import ( + "fmt" "image/color" + "slices" ) -// ────────────────────────────────────────────────────────────────────────────── // -// MARK STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Mark struct { Cell *Cell Change *Change @@ -15,94 +13,222 @@ type Mark struct { } func (m *Mark) GetColor() color.Color { + if m == nil { + return nil + } return m.color } -// ────────────────────────────────────────────────────────────────────────────── // -// CHANGE_ACTION TYPE // -// ────────────────────────────────────────────────────────────────────────────── // - type ChangeAction int const ( - ActionSetNumber = iota + ActionSetNumber ChangeAction = iota ActionSetNote ActionRemoveNumber ActionRemoveNote ) -// ────────────────────────────────────────────────────────────────────────────── // -// CHANGE STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type ExternalChange struct { Cell *Cell Marks []Mark Action ChangeAction Value int From int - TriggerdBy string //strategy or manuell + TriggerdBy string } -// ────────────────────────────────────────────────────────────────────────────── // -// CHANGE STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Change struct { Cell *Cell marks []Mark action ChangeAction value int from int - triggerdBy string //strategy or manuell + triggerdBy string } -// ────────────────────────────────────────────────────────────────────────────── // -// GETTER // -// ────────────────────────────────────────────────────────────────────────────── // - func (c *Change) GetMarks() []Mark { - return c.marks + if c == nil { + return nil + } + return slices.Clone(c.marks) } func (c *Change) GetAction() ChangeAction { + if c == nil { + return ActionSetNumber + } return c.action } func (c *Change) GetTo() int { + if c == nil { + return 0 + } return c.value } func (c *Change) GetFrom() int { + if c == nil { + return 0 + } return c.from } func (c *Change) GetTriggeredBy() string { + if c == nil { + return "" + } return c.triggerdBy } -// ────────────────────────────────────────────────────────────────────────────── // -// ??? // -// ────────────────────────────────────────────────────────────────────────────── // - -func (c *Change) do() { - switch c.action { +func (f *Field) AddChange(external *ExternalChange) error { + if err := f.validateChange(external); err != nil { + return err + } + cell := external.Cell + switch external.Action { case ActionSetNumber: - c.Cell.number = c.value - + if cell.number == external.Value { + return nil + } + cell.number = external.Value + f.recordChange(external) + f.clearNotes(cell, external.TriggerdBy) + f.removePeerNote(cell, external.Value, external.TriggerdBy) case ActionSetNote: - c.Cell.Notes.numbers = append(c.Cell.Notes.numbers, c.value) - + if cell.notes.Has(external.Value) { + return nil + } + cell.notes.numbers = append(cell.notes.numbers, external.Value) + slices.Sort(cell.notes.numbers) + f.recordChange(external) case ActionRemoveNumber: - c.Cell.number = 0 - + if cell.number == 0 { + return nil + } + cell.number = 0 + f.recordChange(external) case ActionRemoveNote: - for i, existing := range c.Cell.Notes.numbers { - if existing == c.from { - c.Cell.Notes.numbers = append(c.Cell.Notes.numbers[:i], c.Cell.Notes.numbers[i+1:]...) - return + for i, existing := range cell.notes.numbers { + if existing == external.From { + cell.notes.numbers = slices.Delete(cell.notes.numbers, i, i+1) + f.recordChange(external) + break } } } + return nil +} + +func (f *Field) AddChanges(changes []ExternalChange) error { + if f == nil || !f.IsValid() { + return ErrInvalidField + } + state := f.snapshot() + for i := range changes { + if err := f.AddChange(&changes[i]); err != nil { + f.restore(state) + return fmt.Errorf("apply change %d: %w", i, err) + } + } + return nil +} + +func (f *Field) GetChanges() []ExternalChange { + if f == nil { + return nil + } + result := make([]ExternalChange, len(f.changes)) + for i := range f.changes { + result[i] = ExternalChange{Cell: f.changes[i].Cell, Marks: slices.Clone(f.changes[i].marks), Action: f.changes[i].action, Value: f.changes[i].value, From: f.changes[i].from, TriggerdBy: f.changes[i].triggerdBy} + } + return result +} + +func (f *Field) validateChange(change *ExternalChange) error { + if f == nil || !f.IsValid() { + return ErrInvalidField + } + if change == nil || change.Cell == nil || !f.ownsCell(change.Cell) { + return fmt.Errorf("%w: cell does not belong to field", ErrInvalidChange) + } + cell := change.Cell + if cell.notes == nil || cell.pos == nil { + return fmt.Errorf("%w: incomplete cell", ErrInvalidChange) + } + + switch change.Action { + case ActionSetNumber: + if change.Value < 1 || change.Value > f.props.Rows { + return fmt.Errorf("%w: number %d is out of range", ErrInvalidChange, change.Value) + } + if change.From != cell.number { + return fmt.Errorf("%w: expected previous value %d, got %d", ErrInvalidChange, cell.number, change.From) + } + if f.hasPeerNumber(cell, change.Value) { + return fmt.Errorf("%w: number %d already exists in a peer", ErrInvalidChange, change.Value) + } + case ActionSetNote: + if cell.number != 0 { + return fmt.Errorf("%w: cannot add a note to a filled cell", ErrInvalidChange) + } + if change.Value < 1 || change.Value > f.props.Rows || f.hasPeerNumber(cell, change.Value) { + return fmt.Errorf("%w: note %d is not a candidate", ErrInvalidChange, change.Value) + } + case ActionRemoveNumber: + if change.From != cell.number { + return fmt.Errorf("%w: expected previous value %d, got %d", ErrInvalidChange, cell.number, change.From) + } + case ActionRemoveNote: + if change.From < 1 || change.From > f.props.Rows { + return fmt.Errorf("%w: note %d is out of range", ErrInvalidChange, change.From) + } + default: + return fmt.Errorf("%w: unknown action %d", ErrInvalidChange, change.Action) + } + return nil +} + +func (f *Field) recordChange(external *ExternalChange) { + f.changes = append(f.changes, Change{Cell: external.Cell, marks: slices.Clone(external.Marks), action: external.Action, value: external.Value, from: external.From, triggerdBy: external.TriggerdBy}) +} + +func (f *Field) clearNotes(cell *Cell, trigger string) { + for _, note := range slices.Clone(cell.notes.numbers) { + cell.notes.numbers = removeNumber(cell.notes.numbers, note) + f.recordChange(&ExternalChange{Cell: cell, Action: ActionRemoveNote, From: note, TriggerdBy: trigger}) + } +} + +func (f *Field) removePeerNote(cell *Cell, number int, trigger string) { + seen := make(map[*Cell]struct{}) + parts, err := f.GetEachPartAtPos(cell.pos) + if err != nil { + return + } + for _, part := range parts { + part.ForEachCell(func(peer *Cell) { + if peer == cell { + return + } + if _, exists := seen[peer]; exists { + return + } + seen[peer] = struct{}{} + if peer.notes.Has(number) { + peer.notes.numbers = removeNumber(peer.notes.numbers, number) + f.recordChange(&ExternalChange{Cell: peer, Action: ActionRemoveNote, From: number, TriggerdBy: trigger}) + } + }) + } +} + +func removeNumber(numbers []int, number int) []int { + for i, existing := range numbers { + if existing == number { + return slices.Delete(numbers, i, i+1) + } + } + return numbers } diff --git a/field/errors.go b/field/errors.go index 35f1a44..42e293e 100644 --- a/field/errors.go +++ b/field/errors.go @@ -3,6 +3,8 @@ package field import "errors" var ( - outOfBound = errors.New("invalid coordinates") - invalidField = errors.New("can not parse Field") + ErrOutOfBounds = errors.New("coordinates out of bounds") + ErrInvalidField = errors.New("invalid field") + ErrInvalidCell = errors.New("invalid cell") + ErrInvalidChange = errors.New("invalid change") ) diff --git a/field/field.go b/field/field.go index 88c1d43..601dfc3 100644 --- a/field/field.go +++ b/field/field.go @@ -2,13 +2,11 @@ package field import ( "fmt" + "slices" + "strconv" "strings" ) -// ────────────────────────────────────────────────────────────────────────────── // -// META STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Properties struct { Rows int Columns int @@ -19,289 +17,445 @@ type Properties struct { Rating float64 } -// ────────────────────────────────────────────────────────────────────────────── // -// FIELD STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Field struct { props *Properties cells [][]Cell changes []Change } -func New(props Properties, cells [][]Cell) *Field { - field := Field{ - props: &props, - cells: cells, - } - return &field +type fieldSnapshot struct { + numbers [][]int + notes [][][]int + changes []Change } -// ────────────────────────────────────────────────────────────────────────────── // -// GETTER // -// ────────────────────────────────────────────────────────────────────────────── // - -func (f *Field) GetRow(r int) (*Row, error) { - if r >= f.props.Rows || r < 0 { - return nil, outOfBound +func New(props Properties, cells [][]Cell) (*Field, error) { + if err := validateProperties(props); err != nil { + return nil, err + } + if len(cells) != props.Rows { + return nil, fmt.Errorf("%w: expected %d rows, got %d", ErrInvalidField, props.Rows, len(cells)) } - cellPtrs := make([]*Cell, len(f.cells[r])) - for i := range f.cells[r] { - cellPtrs[i] = &f.cells[r][i] + cloned := make([][]Cell, props.Rows) + for row := 0; row < props.Rows; row++ { + if len(cells[row]) != props.Columns { + return nil, fmt.Errorf("%w: row %d has %d cells, expected %d", ErrInvalidField, row, len(cells[row]), props.Columns) + } + cloned[row] = make([]Cell, props.Columns) + for column := 0; column < props.Columns; column++ { + source := &cells[row][column] + if source.pos == nil { + return nil, fmt.Errorf("%w: cell %d/%d has no position", ErrInvalidField, row, column) + } + if !positionMatches(source.pos, props, row, column) { + return nil, fmt.Errorf("%w: cell %d/%d has inconsistent position", ErrInvalidField, row, column) + } + if source.number < 0 || source.number > props.Rows { + return nil, fmt.Errorf("%w: cell %d/%d contains %d", ErrInvalidField, row, column, source.number) + } + if source.notes == nil { + return nil, fmt.Errorf("%w: cell %d/%d has no notes", ErrInvalidField, row, column) + } + notes, err := normalizeNotes(source.notes.numbers, props.Rows, source.number) + if err != nil { + return nil, fmt.Errorf("%w: cell %d/%d: %w", ErrInvalidField, row, column, err) + } + cloned[row][column] = Cell{ + number: source.number, + notes: &Notes{numbers: notes}, + pos: NewPosition(row, column, row/props.BlockSizeRow, column/props.BlockSizeColumn, row%props.BlockSizeRow, column%props.BlockSizeColumn), + } + } } - return &Row{ - Line: Line{ - cells: cellPtrs, - }, - }, nil -} - -func (f *Field) GetColumn(c int) (*Column, error) { - if c >= f.props.Columns || c < 0 { - return nil, outOfBound + result := &Field{props: &props, cells: cloned} + if !result.hasValidNumbers() || !result.hasValidNotes() { + return nil, fmt.Errorf("%w: duplicate values or invalid candidates", ErrInvalidField) } - - // Performance - result := &Column{ - Line: Line{ - cells: make([]*Cell, 0, f.props.Rows), - }, - } - - // Copying - for _, row := range f.cells { - result.cells = append(result.cells, &row[c]) - } - return result, nil } -func (f *Field) GetBlock(r, c int) (*Block, error) { - if r < 0 || r >= f.props.BlockRows || c < 0 || c >= f.props.BlockColumns { - return nil, outOfBound +func validateProperties(props Properties) error { + if props.Rows <= 0 || props.Columns <= 0 || props.Rows != props.Columns { + return fmt.Errorf("%w: rows and columns must be equal and positive", ErrInvalidField) } - - startRow := r * f.props.BlockSizeRow - startCol := c * f.props.BlockSizeColumn - - block := &Block{ - cells: make([][]*Cell, f.props.BlockSizeRow), + if props.BlockRows <= 0 || props.BlockColumns <= 0 || props.BlockSizeRow <= 0 || props.BlockSizeColumn <= 0 { + return fmt.Errorf("%w: block dimensions must be positive", ErrInvalidField) } + if props.BlockRows*props.BlockSizeRow != props.Rows || props.BlockColumns*props.BlockSizeColumn != props.Columns { + return fmt.Errorf("%w: block dimensions do not cover the field", ErrInvalidField) + } + if props.BlockSizeRow*props.BlockSizeColumn != props.Rows { + return fmt.Errorf("%w: each block must contain %d cells", ErrInvalidField, props.Rows) + } + return nil +} - for row := range block.cells { - // Effizientes Kopieren der Zeile - block.cells[row] = make([]*Cell, f.props.BlockSizeColumn) - - for column := range block.cells[row] { - block.cells[row][column] = &f.cells[startRow+row][startCol+column] +func normalizeNotes(notes []int, max, cellNumber int) ([]int, error) { + if cellNumber != 0 && len(notes) != 0 { + return nil, fmt.Errorf("filled cells cannot contain notes") + } + result := slices.Clone(notes) + slices.Sort(result) + result = slices.Compact(result) + for _, note := range result { + if note < 1 || note > max { + return nil, fmt.Errorf("note %d is out of range", note) } } - return block, nil + return result, nil } -func (f *Field) GetEachPartAtPos(pos *Position) []Part { +func positionMatches(pos *Position, props Properties, row, column int) bool { + return pos.row == row && pos.column == column && + pos.blockRow == row/props.BlockSizeRow && pos.blockColumn == column/props.BlockSizeColumn && + pos.inBlockRow == row%props.BlockSizeRow && pos.inBlockColumn == column%props.BlockSizeColumn +} + +func (f *Field) GetRow(row int) (*Row, error) { + if !f.hasStructure() || row < 0 || row >= f.props.Rows { + return nil, ErrOutOfBounds + } + cells := make([]*Cell, f.props.Columns) + for column := range f.cells[row] { + cells[column] = &f.cells[row][column] + } + return &Row{Line: Line{cells: cells}}, nil +} + +func (f *Field) GetColumn(column int) (*Column, error) { + if !f.hasStructure() || column < 0 || column >= f.props.Columns { + return nil, ErrOutOfBounds + } + cells := make([]*Cell, f.props.Rows) + for row := range f.cells { + cells[row] = &f.cells[row][column] + } + return &Column{Line: Line{cells: cells}}, nil +} + +func (f *Field) GetBlock(row, column int) (*Block, error) { + if !f.hasStructure() || row < 0 || row >= f.props.BlockRows || column < 0 || column >= f.props.BlockColumns { + return nil, ErrOutOfBounds + } + startRow := row * f.props.BlockSizeRow + startColumn := column * f.props.BlockSizeColumn + cells := make([][]*Cell, f.props.BlockSizeRow) + for blockRow := range cells { + cells[blockRow] = make([]*Cell, f.props.BlockSizeColumn) + for blockColumn := range cells[blockRow] { + cells[blockRow][blockColumn] = &f.cells[startRow+blockRow][startColumn+blockColumn] + } + } + return &Block{cells: cells}, nil +} + +func (f *Field) GetEachPartAtPos(pos *Position) ([]Part, error) { + if pos == nil { + return nil, ErrOutOfBounds + } row, err := f.GetRow(pos.row) if err != nil { - return nil + return nil, err } - column, err := f.GetColumn(pos.column) if err != nil { - return nil + return nil, err } - block, err := f.GetBlock(pos.blockRow, pos.blockColumn) if err != nil { - return nil + return nil, err } - - return append([]Part{}, row, column, block) + return []Part{row, column, block}, nil } -func (f *Field) GetCell(r, c int) (*Cell, error) { - if r > f.props.Rows || c > f.props.Columns { - return nil, outOfBound +func (f *Field) GetCell(row, column int) (*Cell, error) { + if !f.hasStructure() || row < 0 || row >= f.props.Rows || column < 0 || column >= f.props.Columns { + return nil, ErrOutOfBounds } - return &f.cells[r][c], nil + return &f.cells[row][column], nil +} + +func (f *Field) GetProperties() (Properties, error) { + if !f.hasStructure() { + return Properties{}, ErrInvalidField + } + return *f.props, nil } func (f *Field) GetRating() float64 { + if !f.hasStructure() { + return 0 + } return f.props.Rating } -// ────────────────────────────────────────────────────────────────────────────── // -// FOREACH FUNCTIONS // -// ────────────────────────────────────────────────────────────────────────────── // - -func (f *Field) ForEachPart(fn func(part Part)) { - f.ForEachRow(func(row *Row) { - fn(row) - }) - - f.ForEachColumn(func(column *Column) { - fn(column) - }) - - f.ForEachBlock(func(block *Block) { - fn(block) - }) +func (f *Field) ForEachPart(fn func(Part)) { + if !f.hasStructure() || fn == nil { + return + } + f.ForEachRow(func(row *Row) { fn(row) }) + f.ForEachColumn(func(column *Column) { fn(column) }) + f.ForEachBlock(func(block *Block) { fn(block) }) } -func (f *Field) ForEachPartAtPos(pos *Position, fn func(part Part)) { - parts := f.GetEachPartAtPos(pos) +func (f *Field) ForEachPartAtPos(pos *Position, fn func(Part)) error { + if fn == nil { + return nil + } + parts, err := f.GetEachPartAtPos(pos) + if err != nil { + return err + } for _, part := range parts { fn(part) } + return nil +} + +func (f *Field) ForEachRow(fn func(*Row)) { + if !f.hasStructure() || fn == nil { + return + } + for row := 0; row < f.props.Rows; row++ { + part, _ := f.GetRow(row) + fn(part) + } } -func (f *Field) ForEachRow(fn func(row *Row)) { - for i := range f.props.Rows { - row, err := f.GetRow(i) - if err != nil { - fmt.Println(err.Error()) - return - } - fn(row) +func (f *Field) ForEachColumn(fn func(*Column)) { + if !f.hasStructure() || fn == nil { + return + } + for column := 0; column < f.props.Columns; column++ { + part, _ := f.GetColumn(column) + fn(part) } } -func (f *Field) ForEachColumn(fn func(column *Column)) { - for i := range f.props.Columns { - column, err := f.GetColumn(i) - if err != nil { - fmt.Println(err.Error()) - return - } - fn(column) +func (f *Field) ForEachBlock(fn func(*Block)) { + if !f.hasStructure() || fn == nil { + return } - -} - -func (f *Field) ForEachBlock(fn func(block *Block)) { - for r := range f.props.BlockRows { - for c := range f.props.BlockColumns { - block, err := f.GetBlock(r, c) - if err != nil { - fmt.Println(err.Error()) - return - } - fn(block) + for row := 0; row < f.props.BlockRows; row++ { + for column := 0; column < f.props.BlockColumns; column++ { + part, _ := f.GetBlock(row, column) + fn(part) } } } -func (f *Field) ForEachCell(fn func(cell *Cell)) { - for _, row := range f.cells { - for _, cell := range row { - fn(&cell) +func (f *Field) ForEachCell(fn func(*Cell)) { + if !f.hasStructure() || fn == nil { + return + } + for row := range f.cells { + for column := range f.cells[row] { + fn(&f.cells[row][column]) } } } -// ────────────────────────────────────────────────────────────────────────────── // -// MODIFIER // -// ────────────────────────────────────────────────────────────────────────────── // - -func (f *Field) AddChange(eChange *ExternalChange) { - change := Change{ - Cell: eChange.Cell, - marks: eChange.Marks, - action: eChange.Action, - value: eChange.Value, - from: eChange.From, - triggerdBy: eChange.TriggerdBy, - } - - change.do() - - f.changes = append(f.changes, change) -} - func (f *Field) SetRating(rating float64) { - f.props.Rating = rating + if f != nil && f.props != nil { + f.props.Rating = rating + } } -// ────────────────────────────────────────────────────────────────────────────── // -// STATE // -// ────────────────────────────────────────────────────────────────────────────── // - func (f *Field) String() string { - //TODO: auf beliebige größen anpassen - var sb strings.Builder - - // Oberer Rahmen - sb.WriteString("╔═══════╤═══════╤═══════╗\n") - - for i := range f.cells { - sb.WriteString("║ ") // Linke Rahmenseite - - for j, cell := range f.cells[i] { - // Wert ausgeben oder Punkt für 0 - val := cell.number - if val == 0 { - sb.WriteString("·") - } else { - sb.WriteString(string('0' + val)) - } - - // Trennlinien zwischen Blöcken und Zellen - if (j+1)%3 == 0 { - if j < 8 { - sb.WriteString(" │ ") + if !f.hasStructure() { + return "" + } + var result strings.Builder + for row := 0; row < f.props.Rows; row++ { + if row > 0 && row%f.props.BlockSizeRow == 0 { + result.WriteByte('\n') + } + for column := 0; column < f.props.Columns; column++ { + if column > 0 { + if column%f.props.BlockSizeColumn == 0 { + result.WriteString(" | ") } else { - sb.WriteString(" ║\n") // Rechte Rahmenseite + Zeilenumbruch + result.WriteByte(' ') } + } + value := f.cells[row][column].number + if value == 0 { + result.WriteRune('·') } else { - sb.WriteString(" ") + result.WriteString(strconv.Itoa(value)) } } - - // Horizontale Trennlinien nach jeder 3. Zeile - if (i+1)%3 == 0 && i < 8 { - sb.WriteString("╟───────┼───────┼───────╢\n") + if row+1 < f.props.Rows { + result.WriteByte('\n') } } - - // Unterer Rahmen - sb.WriteString("╚═══════╧═══════╧═══════╝") - - return sb.String() + return result.String() } -func (f *Field) StringNotesForNumber(n int) string { - return "StringNotesForNumber is not implementet" +func (f *Field) StringNotesForNumber(number int) string { + if !f.hasStructure() || number < 1 || number > f.props.Rows { + return "" + } + var result strings.Builder + f.ForEachCell(func(cell *Cell) { + if cell.notes.Has(number) { + fmt.Fprintf(&result, "%d/%d\n", cell.pos.row, cell.pos.column) + } + }) + return strings.TrimSuffix(result.String(), "\n") } func (f *Field) StringNotes() string { - var str strings.Builder - fmt.Fprintf(&str, "Notes:\n") + if !f.hasStructure() { + return "Notes:" + } + var result strings.Builder + result.WriteString("Notes:\n") f.ForEachCell(func(cell *Cell) { - fmt.Fprintf(&str, "Pos: %d/%d - Notes: %v\n", cell.Pos.row, cell.Pos.column, cell.Notes.numbers) + fmt.Fprintf(&result, "Pos: %d/%d - Notes: %v\n", cell.pos.row, cell.pos.column, cell.notes.numbers) }) - return str.String() + return strings.TrimSuffix(result.String(), "\n") } func (f *Field) IsSolved() bool { - result := true - f.ForEachCell(func(cell *Cell) { - if cell.number == 0 { - result = false - } - }) - - /* TODO: - if !f.isValid() { + if !f.IsValid() { return false } - */ - - return result + for row := range f.cells { + for column := range f.cells[row] { + if f.cells[row][column].number == 0 { + return false + } + } + } + return true } func (f *Field) IsValid() bool { - //In jedem Part gibt es jede Zahl max ein mal - //Die Zahl kommt nicht in intersecting parts vor + return f.hasStructure() && f.hasValidNumbers() && f.hasValidNotes() +} + +func (f *Field) hasStructure() bool { + if f == nil || f.props == nil || validateProperties(*f.props) != nil || len(f.cells) != f.props.Rows { + return false + } + for row := range f.cells { + if len(f.cells[row]) != f.props.Columns { + return false + } + for column := range f.cells[row] { + cell := &f.cells[row][column] + if cell.notes == nil || cell.pos == nil || !positionMatches(cell.pos, *f.props, row, column) || cell.number < 0 || cell.number > f.props.Rows { + return false + } + } + } + return true +} + +func (f *Field) hasValidNumbers() bool { + if !f.hasStructure() { + return false + } + valid := true + f.ForEachPart(func(part Part) { + seen := make(map[int]struct{}, f.props.Rows) + part.ForEachCell(func(cell *Cell) { + if cell.number == 0 || !valid { + return + } + if _, exists := seen[cell.number]; exists { + valid = false + return + } + seen[cell.number] = struct{}{} + }) + }) + return valid +} + +func (f *Field) hasValidNotes() bool { + if !f.hasStructure() { + return false + } + valid := true + f.ForEachCell(func(cell *Cell) { + if !valid { + return + } + if cell.number != 0 && len(cell.notes.numbers) != 0 { + valid = false + return + } + previous := 0 + for _, note := range cell.notes.numbers { + if note <= previous || note > f.props.Rows || f.hasPeerNumber(cell, note) { + valid = false + return + } + previous = note + } + }) + return valid +} + +func (f *Field) ownsCell(cell *Cell) bool { + if !f.hasStructure() || cell == nil || cell.pos == nil { + return false + } + row, column := cell.pos.row, cell.pos.column + return row >= 0 && row < f.props.Rows && column >= 0 && column < f.props.Columns && &f.cells[row][column] == cell +} + +func (f *Field) hasPeerNumber(cell *Cell, number int) bool { + if !f.ownsCell(cell) { + return false + } + row, column := cell.pos.row, cell.pos.column + for index := 0; index < f.props.Columns; index++ { + if index != column && f.cells[row][index].number == number { + return true + } + } + for index := 0; index < f.props.Rows; index++ { + if index != row && f.cells[index][column].number == number { + return true + } + } + startRow := cell.pos.blockRow * f.props.BlockSizeRow + startColumn := cell.pos.blockColumn * f.props.BlockSizeColumn + for blockRow := 0; blockRow < f.props.BlockSizeRow; blockRow++ { + for blockColumn := 0; blockColumn < f.props.BlockSizeColumn; blockColumn++ { + peer := &f.cells[startRow+blockRow][startColumn+blockColumn] + if peer != cell && peer.number == number { + return true + } + } + } return false } + +func (f *Field) snapshot() fieldSnapshot { + state := fieldSnapshot{numbers: make([][]int, len(f.cells)), notes: make([][][]int, len(f.cells)), changes: slices.Clone(f.changes)} + for row := range f.cells { + state.numbers[row] = make([]int, len(f.cells[row])) + state.notes[row] = make([][]int, len(f.cells[row])) + for column := range f.cells[row] { + state.numbers[row][column] = f.cells[row][column].number + state.notes[row][column] = slices.Clone(f.cells[row][column].notes.numbers) + } + } + return state +} + +func (f *Field) restore(state fieldSnapshot) { + for row := range f.cells { + for column := range f.cells[row] { + f.cells[row][column].number = state.numbers[row][column] + f.cells[row][column].notes.numbers = slices.Clone(state.notes[row][column]) + } + } + f.changes = slices.Clone(state.changes) +} diff --git a/field/field_test.go b/field/field_test.go new file mode 100644 index 0000000..35a2c60 --- /dev/null +++ b/field/field_test.go @@ -0,0 +1,244 @@ +package field + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +const solvedPuzzle = "123456789456789123789123456234567891567891234891234567345678912678912345912345678" + +func classicCells(t *testing.T, digits string) (Properties, [][]Cell) { + t.Helper() + if len(digits) != 81 { + t.Fatalf("test puzzle has %d characters", len(digits)) + } + props := Properties{Rows: 9, Columns: 9, BlockRows: 3, BlockColumns: 3, BlockSizeRow: 3, BlockSizeColumn: 3} + cells := make([][]Cell, props.Rows) + for row := range cells { + cells[row] = make([]Cell, props.Columns) + for column := range cells[row] { + position := NewPosition(row, column, row/3, column/3, row%3, column%3) + cells[row][column] = *NewCell(int(digits[row*9+column]-'0'), position) + } + } + return props, cells +} + +func mustField(t *testing.T, digits string) *Field { + t.Helper() + props, cells := classicCells(t, digits) + result, err := New(props, cells) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return result +} + +func TestNewValidatesStructureAndValues(t *testing.T) { + props, cells := classicCells(t, strings.Repeat("0", 81)) + + tests := []struct { + name string + mutate func(*Properties, [][]Cell) + }{ + {name: "missing row", mutate: func(_ *Properties, cells [][]Cell) { cells[0] = cells[0][:8] }}, + {name: "wrong position", mutate: func(_ *Properties, cells [][]Cell) { cells[0][0].pos = NewPosition(1, 0, 0, 0, 0, 0) }}, + {name: "number out of range", mutate: func(_ *Properties, cells [][]Cell) { cells[0][0].number = 10 }}, + {name: "invalid blocks", mutate: func(props *Properties, _ [][]Cell) { props.BlockRows = 2 }}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + localProps := props + localCells := make([][]Cell, len(cells)) + for row := range cells { + localCells[row] = append([]Cell(nil), cells[row]...) + } + test.mutate(&localProps, localCells) + if _, err := New(localProps, localCells); !errors.Is(err, ErrInvalidField) { + t.Fatalf("New() error = %v, want ErrInvalidField", err) + } + }) + } + + duplicate := "113456789" + solvedPuzzle[9:] + duplicateProps, duplicateCells := classicCells(t, duplicate) + if _, err := New(duplicateProps, duplicateCells); !errors.Is(err, ErrInvalidField) { + t.Fatalf("New() duplicate error = %v, want ErrInvalidField", err) + } +} + +func TestGetCellBoundsAndForEachCellPointers(t *testing.T) { + f := mustField(t, strings.Repeat("0", 81)) + for _, coordinates := range [][2]int{{-1, 0}, {0, -1}, {9, 0}, {0, 9}} { + if _, err := f.GetCell(coordinates[0], coordinates[1]); !errors.Is(err, ErrOutOfBounds) { + t.Errorf("GetCell(%d, %d) error = %v", coordinates[0], coordinates[1], err) + } + } + + want, _ := f.GetCell(4, 5) + var found *Cell + f.ForEachCell(func(cell *Cell) { + if cell.GetPosition().GetRow() == 4 && cell.GetPosition().GetColumn() == 5 { + found = cell + } + }) + if found != want { + t.Fatalf("ForEachCell() returned %p, want stored cell %p", found, want) + } +} + +func TestValidityAndSolvedState(t *testing.T) { + incomplete := mustField(t, strings.Repeat("0", 81)) + if !incomplete.IsValid() || incomplete.IsSolved() { + t.Fatalf("empty field: IsValid=%v IsSolved=%v", incomplete.IsValid(), incomplete.IsSolved()) + } + + solved := mustField(t, solvedPuzzle) + if !solved.IsValid() || !solved.IsSolved() { + t.Fatalf("solved field: IsValid=%v IsSolved=%v", solved.IsValid(), solved.IsSolved()) + } + row, _ := solved.GetRow(0) + block, _ := solved.GetBlock(0, 0) + if !row.IsSolved() || !block.IsSolved() { + t.Fatal("completed row and block must be solved") + } + + solved.cells[0][0].number = solved.cells[0][1].number + if solved.IsValid() || solved.IsSolved() { + t.Fatal("field with a duplicate must be invalid and unsolved") + } +} + +func TestChangesAreValidatedAndAtomic(t *testing.T) { + f := mustField(t, strings.Repeat("0", 81)) + first, _ := f.GetCell(0, 0) + second, _ := f.GetCell(0, 1) + + err := f.AddChanges([]ExternalChange{ + {Cell: first, Action: ActionSetNumber, Value: 1, From: 0}, + {Cell: second, Action: ActionSetNumber, Value: 1, From: 0}, + }) + if !errors.Is(err, ErrInvalidChange) { + t.Fatalf("AddChanges() error = %v, want ErrInvalidChange", err) + } + if first.GetNumber() != 0 || second.GetNumber() != 0 || len(f.GetChanges()) != 0 { + t.Fatal("failed change batch was not rolled back") + } + + foreign := NewCell(0, NewPosition(0, 0, 0, 0, 0, 0)) + if err := f.AddChange(&ExternalChange{Cell: foreign, Action: ActionSetNumber, Value: 1}); !errors.Is(err, ErrInvalidChange) { + t.Fatalf("foreign cell error = %v, want ErrInvalidChange", err) + } + if err := (*Field)(nil).AddChanges(nil); !errors.Is(err, ErrInvalidField) { + t.Fatalf("nil field error = %v, want ErrInvalidField", err) + } +} + +func TestNotesStaySortedUniqueAndArePropagated(t *testing.T) { + f := mustField(t, strings.Repeat("0", 81)) + target, _ := f.GetCell(0, 0) + rowPeer, _ := f.GetCell(0, 1) + columnPeer, _ := f.GetCell(1, 0) + blockPeer, _ := f.GetCell(1, 1) + unrelated, _ := f.GetCell(4, 4) + + for _, note := range []int{5, 1, 5} { + if err := target.GetNotes().Add(f, target, note, "test", nil); err != nil { + t.Fatalf("add target note: %v", err) + } + } + if got, want := target.GetNotes().Get(), []int{1, 5}; !reflect.DeepEqual(got, want) { + t.Fatalf("notes = %v, want %v", got, want) + } + for _, cell := range []*Cell{rowPeer, columnPeer, blockPeer, unrelated} { + if err := cell.GetNotes().Add(f, cell, 5, "test", nil); err != nil { + t.Fatalf("add peer note: %v", err) + } + } + if err := target.SetNumber(f, 5, "test", nil); err != nil { + t.Fatalf("SetNumber() error = %v", err) + } + for _, peer := range []*Cell{target, rowPeer, columnPeer, blockPeer} { + if peer.GetNotes().Has(5) { + row, column := peer.GetPosition().GetCoords() + t.Fatalf("note 5 was not removed at %d/%d", row, column) + } + } + if !unrelated.GetNotes().Has(5) { + t.Fatal("note was removed from an unrelated cell") + } + if f.IsValid() == false { + t.Fatal("field must remain valid after candidate propagation") + } + filled, _ := f.GetCell(0, 2) + if err := filled.SetNumber(f, 2, "test", nil); err != nil { + t.Fatal(err) + } + if err := filled.GetNotes().Add(f, filled, 3, "test", nil); !errors.Is(err, ErrInvalidChange) { + t.Fatalf("note on filled cell error = %v, want ErrInvalidChange", err) + } + if err := rowPeer.GetNotes().Add(f, rowPeer, 2, "test", nil); !errors.Is(err, ErrInvalidChange) { + t.Fatalf("invalid peer note error = %v, want ErrInvalidChange", err) + } +} + +func TestRemoveNumberRecordsCorrectAction(t *testing.T) { + f := mustField(t, strings.Repeat("0", 81)) + cell, _ := f.GetCell(0, 0) + if err := cell.SetNumber(f, 1, "set", nil); err != nil { + t.Fatal(err) + } + if err := cell.RemoveNumber(f, "remove", nil); err != nil { + t.Fatal(err) + } + changes := f.GetChanges() + if changes[len(changes)-1].Action != ActionRemoveNumber { + t.Fatalf("last action = %v, want ActionRemoveNumber", changes[len(changes)-1].Action) + } +} + +func TestStringRendersDigitsAndNotes(t *testing.T) { + f := mustField(t, "900000000"+strings.Repeat("0", 72)) + cell, _ := f.GetCell(0, 1) + if err := cell.GetNotes().Add(f, cell, 1, "test", nil); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(f.String(), "9 · ·") { + t.Fatalf("String() = %q", f.String()) + } + if got := f.StringNotesForNumber(1); got != "0/1" { + t.Fatalf("StringNotesForNumber() = %q, want 0/1", got) + } +} + +func TestGettersReturnDefensiveCopies(t *testing.T) { + f := mustField(t, strings.Repeat("0", 81)) + props, err := f.GetProperties() + if err != nil { + t.Fatal(err) + } + props.Rows = 4 + stored, _ := f.GetProperties() + if stored.Rows != 9 { + t.Fatal("GetProperties exposed internal properties") + } + + cell, _ := f.GetCell(0, 0) + if err := cell.GetNotes().Add(f, cell, 1, "test", nil); err != nil { + t.Fatal(err) + } + notes := cell.GetNotes().Get() + notes[0] = 9 + if !cell.GetNotes().Has(1) || cell.GetNotes().Has(9) { + t.Fatal("Notes.Get exposed its backing slice") + } + + changes := f.GetChanges() + changes[0].Value = 9 + if f.GetChanges()[0].Value != 1 { + t.Fatal("GetChanges exposed its backing slice") + } +} diff --git a/field/parts.go b/field/parts.go index cecbc8f..3a8caf1 100644 --- a/field/parts.go +++ b/field/parts.go @@ -7,83 +7,72 @@ import ( type Part interface { IsSolved() bool - ForEachCell(fn func(cell *Cell)) + ForEachCell(func(*Cell)) GetMissingNumbers() []int - RemoveNote(field *Field, note int, trigger string, marks []Mark) + RemoveNote(*Field, int, string, []Mark) error String() string } -// ────────────────────────────────────────────────────────────────────────────── // -// LINE STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Line struct { cells []*Cell } func (l *Line) IsSolved() bool { - //TODO - return false + return partIsSolved(l) } -func (l *Line) ForEachCell(fn func(cell *Cell)) { +func (l *Line) ForEachCell(fn func(*Cell)) { + if l == nil || fn == nil { + return + } for _, cell := range l.cells { fn(cell) } } func (l *Line) GetMissingNumbers() []int { - return getMissingNumbersHelper(len(l.cells), l.ForEachCell) + if l == nil { + return nil + } + return getMissingNumbers(len(l.cells), l.ForEachCell) } -func (l *Line) RemoveNote(field *Field, note int, trigger string, marks []Mark) { - l.ForEachCell(func(cell *Cell) { - cell.Notes.Remove(field, cell, note, trigger, marks) - }) +func (l *Line) RemoveNote(f *Field, note int, trigger string, marks []Mark) error { + return removeNoteFromPart(l, f, note, trigger, marks) } func (l *Line) String() string { - return lineString(l, "Line") + return partString(l, "Line") } -// ────────────────────────────────────────────────────────────────────────────── // -// ROW STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Row struct { Line } func (r *Row) String() string { - return lineString(r, "Row") + return partString(r, "Row") } -// ────────────────────────────────────────────────────────────────────────────── // -// COLUMN STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Column struct { Line } func (c *Column) String() string { - return lineString(c, "Column") + return partString(c, "Column") } -// ────────────────────────────────────────────────────────────────────────────── // -// BLOCK STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // - type Block struct { cells [][]*Cell } func (b *Block) IsSolved() bool { - //TODO - return false + return partIsSolved(b) } -func (b *Block) ForEachCell(fn func(cell *Cell)) { +func (b *Block) ForEachCell(fn func(*Cell)) { + if b == nil || fn == nil { + return + } for _, row := range b.cells { for _, cell := range row { fn(cell) @@ -92,56 +81,79 @@ func (b *Block) ForEachCell(fn func(cell *Cell)) { } func (b *Block) GetMissingNumbers() []int { - max := len(b.cells) * len(b.cells[0]) - return getMissingNumbersHelper(max, b.ForEachCell) + if b == nil || len(b.cells) == 0 { + return nil + } + size := 0 + for _, row := range b.cells { + size += len(row) + } + return getMissingNumbers(size, b.ForEachCell) } -func (b *Block) RemoveNote(field *Field, note int, trigger string, marks []Mark) { - b.ForEachCell(func(cell *Cell) { - cell.Notes.Remove(field, cell, note, trigger, marks) - }) +func (b *Block) RemoveNote(f *Field, note int, trigger string, marks []Mark) error { + return removeNoteFromPart(b, f, note, trigger, marks) } func (b *Block) String() string { - var str strings.Builder - str.WriteString("Block:\t") - lastLine := 0 - b.ForEachCell(func(cell *Cell) { - fmt.Fprintf(&str, "%d ", cell.number) - if lastLine == cell.Pos.inBlockRow { - - } - }) - - return str.String() + return partString(b, "Block") } -// ────────────────────────────────────────────────────────────────────────────── // -// HELPER // -// ────────────────────────────────────────────────────────────────────────────── // - -func lineString(part Part, name string) string { - var str strings.Builder - fmt.Fprintf(&str, "%s:\t", name) +func partIsSolved(part Part) bool { + if part == nil { + return false + } + cellCount := 0 + seen := make(map[int]struct{}) + valid := true part.ForEachCell(func(cell *Cell) { - fmt.Fprintf(&str, "%d ", cell.number) + cellCount++ + if cell == nil || cell.number <= 0 { + valid = false + return + } + if _, exists := seen[cell.number]; exists { + valid = false + } + seen[cell.number] = struct{}{} }) - return str.String() + return valid && cellCount > 0 && len(seen) == cellCount } -func getMissingNumbersHelper(max int, iterate func(fn func(cell *Cell))) []int { - present := make(map[int]bool, max) +func removeNoteFromPart(part Part, f *Field, note int, trigger string, marks []Mark) error { + var firstErr error + part.ForEachCell(func(cell *Cell) { + if firstErr != nil || !cell.notes.Has(note) { + return + } + firstErr = cell.notes.Remove(f, cell, note, trigger, marks) + }) + return firstErr +} +func partString(part Part, name string) string { + if part == nil { + return name + ":" + } + var result strings.Builder + fmt.Fprintf(&result, "%s:\t", name) + part.ForEachCell(func(cell *Cell) { + fmt.Fprintf(&result, "%d ", cell.GetNumber()) + }) + return strings.TrimSuffix(result.String(), " ") +} + +func getMissingNumbers(max int, iterate func(func(*Cell))) []int { + present := make(map[int]struct{}, max) iterate(func(cell *Cell) { - if cell.number != 0 { - present[cell.number] = true + if cell != nil && cell.number != 0 { + present[cell.number] = struct{}{} } }) - - var missing []int - for i := 1; i <= max; i++ { - if !present[i] { - missing = append(missing, i) + missing := make([]int, 0, max-len(present)) + for number := 1; number <= max; number++ { + if _, exists := present[number]; !exists { + missing = append(missing, number) } } return missing diff --git a/field/position.go b/field/position.go index 1b41c73..14848b0 100644 --- a/field/position.go +++ b/field/position.go @@ -22,37 +22,64 @@ func NewPosition(row, column, blockRow, blockColumn, inBlockRow, inBlockColumn i } func (p *Position) GetRow() int { + if p == nil { + return 0 + } return p.row } func (p *Position) GetColumn() int { + if p == nil { + return 0 + } return p.column } func (p *Position) GetBlockRow() int { + if p == nil { + return 0 + } return p.blockRow } func (p *Position) GetBlockColumn() int { + if p == nil { + return 0 + } return p.blockColumn } func (p *Position) GetInBlockRow() int { + if p == nil { + return 0 + } return p.inBlockRow } func (p *Position) GetInBlockColumn() int { + if p == nil { + return 0 + } return p.inBlockColumn } func (p *Position) GetCoords() (int, int) { + if p == nil { + return 0, 0 + } return p.row, p.column } func (p *Position) GetBlockCoords() (int, int) { + if p == nil { + return 0, 0 + } return p.blockRow, p.blockColumn } func (p *Position) GetInBlockCoords() (int, int) { + if p == nil { + return 0, 0 + } return p.inBlockRow, p.inBlockColumn } diff --git a/logic/solver.go b/logic/solver.go index d87c946..6198dbf 100644 --- a/logic/solver.go +++ b/logic/solver.go @@ -1,65 +1,83 @@ package logic import ( + "errors" + "fmt" + "git.kleiax.de/homepage/field" "git.kleiax.de/homepage/logic/strategies" ) -// ────────────────────────────────────────────────────────────────────────────── // -// SOLVER STRUCTURE // -// ────────────────────────────────────────────────────────────────────────────── // +var ErrNoProgress = errors.New("no strategy can solve the puzzle") type Solver struct { strategies []strategies.Strategy - returnTo int field *field.Field - conf struct { - all bool - repeat bool - } } func (s *Solver) Add(strategy strategies.Strategy) { - s.strategies = append(s.strategies, strategy) -} - -func (s *Solver) InitStragies(field *field.Field) { - s.returnTo = 1 - s.field = field - for _, strategy := range s.strategies { - strategy.Init(field) + if strategy != nil { + s.strategies = append(s.strategies, strategy) } } -func (s *Solver) Run(i int) bool { - for j := 0; j < len(s.strategies); j++ { - if s.strategies[j].SearchProgressableCells() == 0 { - continue +func (s *Solver) InitStrategies(f *field.Field) error { + if f == nil || !f.IsValid() { + return field.ErrInvalidField + } + s.field = f + for _, strategy := range s.strategies { + strategy.Init(f) + } + return nil +} + +// InitStragies is kept for compatibility. New code should use InitStrategies. +func (s *Solver) InitStragies(f *field.Field) error { + return s.InitStrategies(f) +} + +// Run applies strategies from the requested index until the field is solved or +// no strategy can make progress. It returns false without an error for a valid +// but currently unsolved field. +func (s *Solver) Run(start int) (bool, error) { + if s.field == nil || !s.field.IsValid() { + return false, field.ErrInvalidField + } + if start < 0 || start > len(s.strategies) { + return false, fmt.Errorf("strategy index %d out of range", start) + } + + for { + if s.field.IsSolved() { + return true, nil } - for _, change := range s.strategies[j].ApplyAll() { - if change.Action != field.ActionSetNumber { + progress := false + for index := start; index < len(s.strategies); index++ { + strategy := s.strategies[index] + if strategy.SearchProgressableCells() == 0 { continue } - s.field.ForEachPartAtPos(change.Cell.Pos, func(part field.Part) { - part.ForEachCell(func(cell *field.Cell) { - cell.Notes.Remove(s.field, cell, change.Value, "remove note after insert of a number", nil) - }) - }) + changes, err := strategy.ApplyAll() + if err != nil { + return false, fmt.Errorf("apply strategy %q: %w", strategy.Name(), err) + } + if len(changes) == 0 { + continue + } + progress = true + break } - j = s.returnTo - 1 + if !progress { + return false, nil + } + start = 0 } - - if s.field.IsSolved() { - return true - } - return false } -func (s *Solver) Search() { - -} +func (s *Solver) Search() {} func (s *Solver) GetSolutionPath() []field.ExternalChange { return nil diff --git a/logic/solver_test.go b/logic/solver_test.go new file mode 100644 index 0000000..9636980 --- /dev/null +++ b/logic/solver_test.go @@ -0,0 +1,71 @@ +package logic_test + +import ( + "errors" + "testing" + + "git.kleiax.de/homepage/field" + "git.kleiax.de/homepage/logic" + "git.kleiax.de/homepage/logic/strategies" + "git.kleiax.de/homepage/parser" +) + +const solvedPuzzle = "123456789456789123789123456234567891567891234891234567345678912678912345912345678" + +func parsedField(t *testing.T, puzzle string) *field.Field { + t.Helper() + input := &parser.PuzzleString{} + if err := input.Parse([]byte(puzzle)); err != nil { + t.Fatal(err) + } + result, err := input.GetField(0) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestSolverRunSolvesAndDetectsNoProgress(t *testing.T) { + t.Run("solved", func(t *testing.T) { + f := parsedField(t, "0"+solvedPuzzle[1:]) + solver := &logic.Solver{} + solver.Add(&strategies.LastDigit{}) + if err := solver.InitStrategies(f); err != nil { + t.Fatal(err) + } + solved, err := solver.Run(0) + if err != nil || !solved || !f.IsSolved() { + t.Fatalf("Run() = (%v, %v), field solved = %v", solved, err, f.IsSolved()) + } + }) + + t.Run("no progress", func(t *testing.T) { + f := parsedField(t, "0"+solvedPuzzle[1:]) + solver := &logic.Solver{} + if err := solver.InitStrategies(f); err != nil { + t.Fatal(err) + } + solved, err := solver.Run(0) + if err != nil || solved { + t.Fatalf("Run() = (%v, %v), want (false, nil)", solved, err) + } + }) +} + +func TestSolverRejectsInvalidStateAndStrategyIndex(t *testing.T) { + solver := &logic.Solver{} + if _, err := solver.Run(0); !errors.Is(err, field.ErrInvalidField) { + t.Fatalf("Run() before initialization error = %v", err) + } + if err := solver.InitStrategies(nil); !errors.Is(err, field.ErrInvalidField) { + t.Fatalf("InitStrategies(nil) error = %v", err) + } + + f := parsedField(t, "0"+solvedPuzzle[1:]) + if err := solver.InitStrategies(f); err != nil { + t.Fatal(err) + } + if _, err := solver.Run(1); err == nil { + t.Fatal("Run() with invalid strategy index error = nil") + } +} diff --git a/logic/strategies/divers.go b/logic/strategies/divers.go index 2496687..704f5df 100644 --- a/logic/strategies/divers.go +++ b/logic/strategies/divers.go @@ -17,13 +17,16 @@ func (ld *LastDigit) SearchProgressableCells() int { missingNumbers := part.GetMissingNumbers() if len(missingNumbers) == 1 { var emptyCell *field.Cell + emptyCount := 0 part.ForEachCell(func(cell *field.Cell) { if cell.GetNumber() == 0 { emptyCell = cell + emptyCount++ } }) - // fmt.Println(part) - // fmt.Printf("Gefundene Zelle: %d/%d - %d, missungNumber: %v, Typ: %T\n", emptyCell.Pos.GetRow(), emptyCell.Pos.GetColumn(), emptyCell.GetNumber(), missingNumbers, part) + if emptyCount != 1 { + return + } change := field.ExternalChange{ Cell: emptyCell, Action: field.ActionSetNumber, @@ -31,10 +34,9 @@ func (ld *LastDigit) SearchProgressableCells() int { From: emptyCell.GetNumber(), TriggerdBy: ld.getName(), } - ld.changes = append(ld.changes, change) + ld.queue(change) } }) - // fmt.Printf("LastDigit Changes %d\n", len(ld.changes)) return len(ld.changes) } @@ -42,6 +44,10 @@ func (ld *LastDigit) getName() string { return "Last Digit" } +func (ld *LastDigit) Name() string { + return ld.getName() +} + // ────────────────────────────────────────────────────────────────────────────── // // CRP STRUCTURE // // ────────────────────────────────────────────────────────────────────────────── // diff --git a/logic/strategies/hidden.go b/logic/strategies/hidden.go index 1c523f4..01dc165 100644 --- a/logic/strategies/hidden.go +++ b/logic/strategies/hidden.go @@ -15,8 +15,8 @@ func (hs *HiddenSingle) SearchProgressableCells() int { m := make(map[int]int) part.ForEachCell(func(cell *field.Cell) { - notes := cell.Notes.Get() - for note := range notes { + notes := cell.GetNotes().Get() + for _, note := range notes { m[note]++ } }) @@ -28,9 +28,9 @@ func (hs *HiddenSingle) SearchProgressableCells() int { } } - for num := range hiddenSingles { + for _, num := range hiddenSingles { part.ForEachCell(func(cell *field.Cell) { - if cell.Notes.Has(num) { + if cell.GetNumber() == 0 && cell.GetNotes().Has(num) { change := field.ExternalChange{ Cell: cell, Action: field.ActionSetNumber, @@ -38,7 +38,7 @@ func (hs *HiddenSingle) SearchProgressableCells() int { From: cell.GetNumber(), TriggerdBy: hs.getName(), } - hs.changes = append(hs.changes, change) + hs.queue(change) } }) } @@ -50,6 +50,10 @@ func (hs *HiddenSingle) getName() string { return "Hidden Single" } +func (hs *HiddenSingle) Name() string { + return hs.getName() +} + // ────────────────────────────────────────────────────────────────────────────── // // HIDDEN_PAIR STRUCTURE // // ────────────────────────────────────────────────────────────────────────────── // diff --git a/logic/strategies/naked.go b/logic/strategies/naked.go index 3aa214b..89ad806 100644 --- a/logic/strategies/naked.go +++ b/logic/strategies/naked.go @@ -13,7 +13,7 @@ type NakedSingle struct { func (ns *NakedSingle) SearchProgressableCells() int { ns.field.ForEachPart(func(part field.Part) { part.ForEachCell(func(cell *field.Cell) { - candidates := cell.Notes.Get() + candidates := cell.GetNotes().Get() if len(candidates) == 1 && cell.GetNumber() == 0 { change := field.ExternalChange{ Cell: cell, @@ -22,7 +22,7 @@ func (ns *NakedSingle) SearchProgressableCells() int { From: cell.GetNumber(), TriggerdBy: ns.getName(), } - ns.changes = append(ns.changes, change) + ns.queue(change) } }) }) @@ -33,6 +33,10 @@ func (ns *NakedSingle) getName() string { return "Naked Single" } +func (ns *NakedSingle) Name() string { + return ns.getName() +} + // ────────────────────────────────────────────────────────────────────────────── // // NAKED_DOUBLE STRUCTURE // // ────────────────────────────────────────────────────────────────────────────── // diff --git a/logic/strategies/notes.go b/logic/strategies/notes.go index 841416f..5ebecb2 100644 --- a/logic/strategies/notes.go +++ b/logic/strategies/notes.go @@ -1,6 +1,8 @@ package strategies import ( + "sort" + "git.kleiax.de/homepage/field" ) @@ -15,11 +17,12 @@ type Notes struct { func (n *Notes) SearchProgressableCells() int { n.field.ForEachRow(func(row *field.Row) { row.ForEachCell(func(cell *field.Cell) { - column, _ := n.field.GetColumn(cell.Pos.GetColumn()) - block, _ := n.field.GetBlock(cell.Pos.GetBlockRow(), cell.Pos.GetBlockColumn()) + pos := cell.GetPosition() + column, _ := n.field.GetColumn(pos.GetColumn()) + block, _ := n.field.GetBlock(pos.GetBlockRow(), pos.GetBlockColumn()) candidates := intersection3(row.GetMissingNumbers(), column.GetMissingNumbers(), block.GetMissingNumbers()) for _, note := range candidates { - if cell.Notes.Has(note) || cell.GetNumber() != 0 { + if cell.GetNotes().Has(note) || cell.GetNumber() != 0 { continue } ch := field.ExternalChange{ @@ -30,7 +33,7 @@ func (n *Notes) SearchProgressableCells() int { Marks: nil, From: 0, } - n.changes = append(n.changes, ch) + n.queue(ch) } }) }) @@ -41,6 +44,10 @@ func (n *Notes) getName() string { return "Make Notes" } +func (n *Notes) Name() string { + return n.getName() +} + func intersection3(a, b, c []int) []int { set := make(map[int]bool) @@ -76,6 +83,7 @@ func intersection3(a, b, c []int) []int { for v := range set { result = append(result, v) } + sort.Ints(result) return result } diff --git a/logic/strategies/strategies_test.go b/logic/strategies/strategies_test.go new file mode 100644 index 0000000..766ef9b --- /dev/null +++ b/logic/strategies/strategies_test.go @@ -0,0 +1,82 @@ +package strategies + +import ( + "strings" + "testing" + + "git.kleiax.de/homepage/field" +) + +const solvedGrid = "123456789456789123789123456234567891567891234891234567345678912678912345912345678" + +func strategyField(t *testing.T, digits string) *field.Field { + t.Helper() + props := field.Properties{Rows: 9, Columns: 9, BlockRows: 3, BlockColumns: 3, BlockSizeRow: 3, BlockSizeColumn: 3} + cells := make([][]field.Cell, 9) + for row := range cells { + cells[row] = make([]field.Cell, 9) + for column := range cells[row] { + pos := field.NewPosition(row, column, row/3, column/3, row%3, column%3) + cells[row][column] = *field.NewCell(int(digits[row*9+column]-'0'), pos) + } + } + result, err := field.New(props, cells) + if err != nil { + t.Fatal(err) + } + return result +} + +func TestLastDigitDeduplicatesOverlappingParts(t *testing.T) { + f := strategyField(t, "0"+solvedGrid[1:]) + strategy := &LastDigit{} + strategy.Init(f) + if got := strategy.SearchProgressableCells(); got != 1 { + t.Fatalf("SearchProgressableCells() = %d, want 1", got) + } + changes, err := strategy.ApplyAll() + if err != nil { + t.Fatalf("ApplyAll() error = %v", err) + } + if len(changes) != 1 || !f.IsSolved() { + t.Fatalf("changes = %d, solved = %v", len(changes), f.IsSolved()) + } +} + +func TestHiddenSingleUsesCandidateValuesNotSliceIndexes(t *testing.T) { + f := strategyField(t, strings.Repeat("0", 81)) + first, _ := f.GetCell(0, 0) + second, _ := f.GetCell(0, 1) + third, _ := f.GetCell(0, 2) + for _, setup := range []struct { + cell *field.Cell + note int + }{{first, 9}, {second, 8}, {third, 8}} { + if err := setup.cell.GetNotes().Add(f, setup.cell, setup.note, "test", nil); err != nil { + t.Fatal(err) + } + } + + strategy := &HiddenSingle{} + strategy.Init(f) + strategy.SearchProgressableCells() + found := false + for _, change := range strategy.changes { + if change.Cell == first && change.Value == 9 { + found = true + } + } + if !found { + t.Fatal("hidden single for candidate value 9 was not found") + } +} + +func TestIntersection3IsSorted(t *testing.T) { + got := intersection3([]int{9, 1, 5}, []int{5, 9, 1}, []int{9, 5, 1}) + want := []int{1, 5, 9} + for i := range want { + if got[i] != want[i] { + t.Fatalf("intersection3() = %v, want %v", got, want) + } + } +} diff --git a/logic/strategies/strategy.go b/logic/strategies/strategy.go index 7e40bff..c4c0268 100644 --- a/logic/strategies/strategy.go +++ b/logic/strategies/strategy.go @@ -12,8 +12,8 @@ import ( type Strategy interface { Init(f *field.Field) - ApplyAll() []field.ExternalChange - ApplyNext() field.ExternalChange + ApplyAll() ([]field.ExternalChange, error) + ApplyNext() (field.ExternalChange, error) Name() string SearchProgressableCells() int } @@ -40,35 +40,40 @@ func (b *Base) Init(f *field.Field) { b.field = f } -func (b *Base) ApplyAll() []field.ExternalChange { +func (b *Base) ApplyAll() ([]field.ExternalChange, error) { changesCopy := make([]field.ExternalChange, len(b.changes)) copy(changesCopy, b.changes) - - for _, change := range b.changes { - b.field.AddChange(&change) - } - b.changes = b.changes[:0] - - return changesCopy + if err := b.field.AddChanges(changesCopy); err != nil { + return nil, err + } + return changesCopy, nil } -func (b *Base) ApplyNext() field.ExternalChange { +func (b *Base) ApplyNext() (field.ExternalChange, error) { if len(b.changes) < 1 { - return field.ExternalChange{} + return field.ExternalChange{}, nil } - changeCopy := b.changes[0] - - b.field.AddChange(&b.changes[0]) - b.changes = b.changes[1:] + if err := b.field.AddChange(&changeCopy); err != nil { + return field.ExternalChange{}, err + } + return changeCopy, nil +} - return changeCopy +func (b *Base) queue(change field.ExternalChange) { + for _, existing := range b.changes { + if existing.Cell == change.Cell && existing.Action == change.Action && + existing.Value == change.Value && existing.From == change.From { + return + } + } + b.changes = append(b.changes, change) } func (b *Base) getName() string { - return "Unkown" + return "Unknown" } func (b *Base) Name() string { diff --git a/main.go b/main.go index 8627f1e..da1a6f8 100644 --- a/main.go +++ b/main.go @@ -17,7 +17,7 @@ func main() { solver.Add(&strategies.LastDigit{}) solver.Add(&strategies.NakedSingle{}) solver.Add(&strategies.HiddenSingle{}) - game, err := sudoku.New(&parser.PuzzleBank{}, solver, openFile()) + game, err := sudoku.New(&parser.PuzzleBank{}, solver, openFile(), 2) if err != nil { fmt.Println(err.Error()) os.Exit(1) diff --git a/parser/parser.go b/parser/parser.go index 63f824a..99d846a 100644 --- a/parser/parser.go +++ b/parser/parser.go @@ -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 } diff --git a/parser/parser_test.go b/parser/parser_test.go new file mode 100644 index 0000000..6aaf871 --- /dev/null +++ b/parser/parser_test.go @@ -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) + } + } +} diff --git a/parser/puzzleBank.go b/parser/puzzleBank.go index 48884f3..f69e3fa 100644 --- a/parser/puzzleBank.go +++ b/parser/puzzleBank.go @@ -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 } diff --git a/sudoku/sudoku.go b/sudoku/sudoku.go index 96e7ba9..10a2bc0 100644 --- a/sudoku/sudoku.go +++ b/sudoku/sudoku.go @@ -14,36 +14,43 @@ type Game struct { solver logic.Solver } -func New(parser parser.Parser, solver logic.Solver, data []byte) (*Game, error) { - err := parser.Parse(data) - if err != nil { - return &Game{}, fmt.Errorf("can not create game: %w", err) +func New(input parser.Parser, solver logic.Solver, data []byte, fieldIndex int) (*Game, error) { + if input == nil { + return nil, errors.New("cannot create game: nil parser") } - return &Game{solver: solver, field: parser.GetField(2)}, nil + if err := input.Parse(data); err != nil { + return nil, fmt.Errorf("cannot create game: %w", err) + } + parsedField, err := input.GetField(fieldIndex) + if err != nil { + return nil, fmt.Errorf("cannot create game: %w", err) + } + if parsedField == nil || !parsedField.IsValid() { + return nil, fmt.Errorf("cannot create game: %w", field.ErrInvalidField) + } + return &Game{solver: solver, field: parsedField}, nil } func (g *Game) Solve() error { - g.solver.InitStragies(g.field) - for !g.isFinished() { - if !g.nextSolveStep() { - return errors.New("no strategy can solve the puzzle") - } + if g == nil || g.field == nil { + return field.ErrInvalidField + } + if err := g.solver.InitStrategies(g.field); err != nil { + return err + } + solved, err := g.solver.Run(0) + if err != nil { + return err + } + if !solved { + return logic.ErrNoProgress } return nil } -func (g *Game) nextSolveStep() bool { - return g.solver.Run(0) -} - -func (g *Game) prevSolveStep() { - // far far in the future -} - -func (g *Game) isFinished() bool { - return g.field.IsSolved() -} - -func (g *Game) GetField() field.Field { - return *g.field +func (g *Game) GetField() *field.Field { + if g == nil { + return nil + } + return g.field } diff --git a/sudoku/sudoku_test.go b/sudoku/sudoku_test.go new file mode 100644 index 0000000..f913b74 --- /dev/null +++ b/sudoku/sudoku_test.go @@ -0,0 +1,65 @@ +package sudoku + +import ( + "crypto/sha1" + "errors" + "fmt" + "testing" + + "git.kleiax.de/homepage/logic" + "git.kleiax.de/homepage/logic/strategies" + "git.kleiax.de/homepage/parser" +) + +const solvedSudoku = "123456789456789123789123456234567891567891234891234567345678912678912345912345678" + +func puzzleBankRecord(digits string, rating float64) string { + hash := fmt.Sprintf("%x", sha1.Sum([]byte(digits)))[:12] + return fmt.Sprintf("%s %s %.1f", hash, digits, rating) +} + +func TestNewSelectsExplicitPuzzleIndex(t *testing.T) { + data := puzzleBankRecord("0"+solvedSudoku[1:], 1.0) + "\n" + puzzleBankRecord(solvedSudoku, 2.0) + game, err := New(&parser.PuzzleBank{}, logic.Solver{}, []byte(data), 1) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if game.GetField().GetRating() != 2 || !game.GetField().IsSolved() { + t.Fatal("New() did not select puzzle index 1") + } +} + +func TestNewRejectsInvalidIndexAndInput(t *testing.T) { + valid := puzzleBankRecord(solvedSudoku, 1.0) + if _, err := New(&parser.PuzzleBank{}, logic.Solver{}, []byte(valid), 1); !errors.Is(err, parser.ErrFieldIndex) { + t.Fatalf("index error = %v, want ErrFieldIndex", err) + } + if _, err := New(&parser.PuzzleString{}, logic.Solver{}, []byte("invalid"), 0); err == nil { + t.Fatal("invalid input error = nil") + } +} + +func TestSolveCompletesSimplePuzzle(t *testing.T) { + solver := logic.Solver{} + solver.Add(&strategies.LastDigit{}) + game, err := New(&parser.PuzzleString{}, solver, []byte("0"+solvedSudoku[1:]), 0) + if err != nil { + t.Fatal(err) + } + if err := game.Solve(); err != nil { + t.Fatalf("Solve() error = %v", err) + } + if !game.GetField().IsSolved() { + t.Fatal("field is not solved") + } +} + +func TestSolveReportsNoProgress(t *testing.T) { + game, err := New(&parser.PuzzleString{}, logic.Solver{}, []byte("0"+solvedSudoku[1:]), 0) + if err != nil { + t.Fatal(err) + } + if err := game.Solve(); !errors.Is(err, logic.ErrNoProgress) { + t.Fatalf("Solve() error = %v, want ErrNoProgress", err) + } +} diff --git a/todo.md b/todo.md index 84c3ab0..0cc7771 100644 --- a/todo.md +++ b/todo.md @@ -1,11 +1,10 @@ -- Feld immer wieder auf Validität checken (keine doppelten Zahlen in einem Part) damit keine Fehler bei den Strategien auftauchen können - - Validator in Logic oder Field? - - kommt in field - - field sollte flag haben ob vor einem change geprüft wird, ob das Feld danach noch valid ist -- Für die solver ein Interface implementieren, damit die folgenden Solver benutzt werden können: - - backtracking Solver - - dlx solver - - notes (menschlicher solver) -- Was passiert wenn zum Beispiel bei hiddenSingle ein Treffer zwei Changes auslöst? Muss ein Überprüfung her, ob die betreffende Zelle schon in einem vorheringen Change bearbeitet wird? Ich denka ja -- Debugging verbesser, Loglevel etc, überall sinnvolle Logs einbauen -- Flag oder so für sich selbst überprüfende Sachen, nur im debug modus nach jedem bisschen das Feld auf validität prüfen \ No newline at end of file +# Offene Punkte + +Der priorisierte und aktuelle Stand befindet sich in [ROADMAP.md](ROADMAP.md). + +- gemeinsames Interface für menschlichen Solver, Backtracking und DLX + implementieren +- strukturiertes, optionales Logging an den Anwendungsgrenzen ergänzen +- optionalen Diagnosemodus für zusätzliche Invariantenprüfungen entwickeln +- Lösungsweg, Gruppierung von Änderungen und Undo/Redo implementieren +- weitere menschliche Lösungsstrategien in der Reihenfolge der Roadmap ergänzen