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
+244
View File
@@ -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")
}
}