clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

nonogram, sudoku, and hashi tests

Dami committed cd22638
commit
cd22638aa0e504b8889900fd1d548225530679ba
parent
dee3a53964aff46038fa03b8caad78babeeb474f
author
Dami <dami.etoile@gmail.com>
6 files changed, +3518 -1
Agame/game_test.go366+++++++++++
Ahashiwokakero/hashiwokakero_test.go994++++++++++++++++++++++++++++++
Anonogram/nonogram_test.go582++++++++++++++++++
Asudoku/sudoku_test.go657++++++++++++++++++++
Mtakuzu/style.go2+-
Atakuzu/takuzu_test.go918++++++++++++++++++++++++++++
A game/game_test.go +366 -0
@@ -0,0 +1,366 @@
1+package game2+3+import (4+ "strings"5+ "testing"6+7+ tea "github.com/charmbracelet/bubbletea"8+)9+10+// --- Cursor.Move (P0) ---11+12+func TestCursorMove(t *testing.T) {13+ keys := DefaultCursorKeyMap14+15+ keyMsg := func(k string) tea.KeyMsg {16+ return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}17+ }18+ specialKeyMsg := func(k tea.KeyType) tea.KeyMsg {19+ return tea.KeyMsg{Type: k}20+ }21+22+ t.Run("movement within bounds", func(t *testing.T) {23+ tests := []struct {24+ name string25+ startX int26+ startY int27+ msg tea.KeyMsg28+ maxX int29+ maxY int30+ wantX int31+ wantY int32+ wantMoved bool33+ }{34+ {"move up within bounds", 2, 2, specialKeyMsg(tea.KeyUp), 4, 4, 2, 1, true},35+ {"move down within bounds", 2, 2, specialKeyMsg(tea.KeyDown), 4, 4, 2, 3, true},36+ {"move left within bounds", 2, 2, specialKeyMsg(tea.KeyLeft), 4, 4, 1, 2, true},37+ {"move right within bounds", 2, 2, specialKeyMsg(tea.KeyRight), 4, 4, 3, 2, true},38+ }39+40+ for _, tt := range tests {41+ t.Run(tt.name, func(t *testing.T) {42+ c := Cursor{X: tt.startX, Y: tt.startY}43+ moved := c.Move(keys, tt.msg, tt.maxX, tt.maxY)44+ if moved != tt.wantMoved {45+ t.Errorf("Move() = %v, want %v", moved, tt.wantMoved)46+ }47+ if c.X != tt.wantX || c.Y != tt.wantY {48+ t.Errorf("cursor = (%d,%d), want (%d,%d)", c.X, c.Y, tt.wantX, tt.wantY)49+ }50+ })51+ }52+ })53+54+ t.Run("blocked at edges", func(t *testing.T) {55+ tests := []struct {56+ name string57+ startX int58+ startY int59+ msg tea.KeyMsg60+ maxX int61+ maxY int62+ }{63+ {"blocked at top edge", 2, 0, specialKeyMsg(tea.KeyUp), 4, 4},64+ {"blocked at bottom edge", 2, 4, specialKeyMsg(tea.KeyDown), 4, 4},65+ {"blocked at left edge", 0, 2, specialKeyMsg(tea.KeyLeft), 4, 4},66+ {"blocked at right edge", 4, 2, specialKeyMsg(tea.KeyRight), 4, 4},67+ }68+69+ for _, tt := range tests {70+ t.Run(tt.name, func(t *testing.T) {71+ c := Cursor{X: tt.startX, Y: tt.startY}72+ moved := c.Move(keys, tt.msg, tt.maxX, tt.maxY)73+ if moved {74+ t.Error("Move() = true, want false at edge")75+ }76+ if c.X != tt.startX || c.Y != tt.startY {77+ t.Errorf("cursor moved to (%d,%d), want (%d,%d)", c.X, c.Y, tt.startX, tt.startY)78+ }79+ })80+ }81+ })82+83+ t.Run("1x1 grid no movement", func(t *testing.T) {84+ directions := []tea.KeyMsg{85+ specialKeyMsg(tea.KeyUp),86+ specialKeyMsg(tea.KeyDown),87+ specialKeyMsg(tea.KeyLeft),88+ specialKeyMsg(tea.KeyRight),89+ }90+ for _, msg := range directions {91+ c := Cursor{X: 0, Y: 0}92+ moved := c.Move(keys, msg, 0, 0)93+ if moved {94+ t.Errorf("Move() = true on 1x1 grid for key %v", msg)95+ }96+ if c.X != 0 || c.Y != 0 {97+ t.Errorf("cursor = (%d,%d), want (0,0)", c.X, c.Y)98+ }99+ }100+ })101+102+ t.Run("arrow keys work", func(t *testing.T) {103+ tests := []struct {104+ msg tea.KeyMsg105+ wantX int106+ wantY int107+ }{108+ {specialKeyMsg(tea.KeyUp), 2, 1},109+ {specialKeyMsg(tea.KeyDown), 2, 3},110+ {specialKeyMsg(tea.KeyLeft), 1, 2},111+ {specialKeyMsg(tea.KeyRight), 3, 2},112+ }113+ for _, tt := range tests {114+ c := Cursor{X: 2, Y: 2}115+ c.Move(keys, tt.msg, 4, 4)116+ if c.X != tt.wantX || c.Y != tt.wantY {117+ t.Errorf("arrow key: cursor = (%d,%d), want (%d,%d)", c.X, c.Y, tt.wantX, tt.wantY)118+ }119+ }120+ })121+122+ t.Run("WASD keys work", func(t *testing.T) {123+ tests := []struct {124+ key string125+ wantX int126+ wantY int127+ }{128+ {"w", 2, 1},129+ {"s", 2, 3},130+ {"a", 1, 2},131+ {"d", 3, 2},132+ }133+ for _, tt := range tests {134+ c := Cursor{X: 2, Y: 2}135+ c.Move(keys, keyMsg(tt.key), 4, 4)136+ if c.X != tt.wantX || c.Y != tt.wantY {137+ t.Errorf("WASD %q: cursor = (%d,%d), want (%d,%d)", tt.key, c.X, c.Y, tt.wantX, tt.wantY)138+ }139+ }140+ })141+142+ t.Run("vim keys work", func(t *testing.T) {143+ tests := []struct {144+ key string145+ wantX int146+ wantY int147+ }{148+ {"k", 2, 1},149+ {"j", 2, 3},150+ {"h", 1, 2},151+ {"l", 3, 2},152+ }153+ for _, tt := range tests {154+ c := Cursor{X: 2, Y: 2}155+ c.Move(keys, keyMsg(tt.key), 4, 4)156+ if c.X != tt.wantX || c.Y != tt.wantY {157+ t.Errorf("vim %q: cursor = (%d,%d), want (%d,%d)", tt.key, c.X, c.Y, tt.wantX, tt.wantY)158+ }159+ }160+ })161+162+ t.Run("unrelated key no movement", func(t *testing.T) {163+ c := Cursor{X: 2, Y: 2}164+ moved := c.Move(keys, specialKeyMsg(tea.KeyEnter), 4, 4)165+ if moved {166+ t.Error("Move() = true for unrelated key, want false")167+ }168+ if c.X != 2 || c.Y != 2 {169+ t.Errorf("cursor = (%d,%d), want (2,2)", c.X, c.Y)170+ }171+ })172+}173+174+// --- BaseMode (P1) ---175+176+func TestBaseMode(t *testing.T) {177+ t.Run("normal values", func(t *testing.T) {178+ b := NewBaseMode("Easy", "5x5 grid")179+ if got := b.Title(); got != "Easy" {180+ t.Errorf("Title() = %q, want %q", got, "Easy")181+ }182+ if got := b.Description(); got != "5x5 grid" {183+ t.Errorf("Description() = %q, want %q", got, "5x5 grid")184+ }185+ if got := b.FilterValue(); got != "Easy 5x5 grid" {186+ t.Errorf("FilterValue() = %q, want %q", got, "Easy 5x5 grid")187+ }188+ })189+190+ t.Run("empty strings", func(t *testing.T) {191+ b := NewBaseMode("", "")192+ if got := b.Title(); got != "" {193+ t.Errorf("Title() = %q, want %q", got, "")194+ }195+ if got := b.Description(); got != "" {196+ t.Errorf("Description() = %q, want %q", got, "")197+ }198+ if got := b.FilterValue(); got != " " {199+ t.Errorf("FilterValue() = %q, want %q", got, " ")200+ }201+ })202+}203+204+// --- Category (P3) ---205+206+func TestCategory(t *testing.T) {207+ c := Category{Name: "Sudoku", Desc: "Classic"}208+209+ if got := c.Title(); got != "Sudoku" {210+ t.Errorf("Title() = %q, want %q", got, "Sudoku")211+ }212+ if got := c.Description(); got != "Classic" {213+ t.Errorf("Description() = %q, want %q", got, "Classic")214+ }215+ if got := c.FilterValue(); got != "Sudoku" {216+ t.Errorf("FilterValue() = %q, want %q", got, "Sudoku")217+ }218+}219+220+// --- Registry (P1) ---221+222+func TestRegistry(t *testing.T) {223+ // Save and restore the global registry to avoid pollution.224+ origRegistry := make(map[string]func([]byte) (Gamer, error))225+ for k, v := range Registry {226+ origRegistry[k] = v227+ }228+ t.Cleanup(func() {229+ Registry = origRegistry230+ })231+232+ t.Run("Register adds function", func(t *testing.T) {233+ called := false234+ fn := func(data []byte) (Gamer, error) {235+ called = true236+ return nil, nil237+ }238+ Register("test-game", fn)239+ if Registry["test-game"] == nil {240+ t.Fatal("expected registry entry to be set")241+ }242+ if _, err := Registry["test-game"](nil); err != nil {243+ t.Fatal(err)244+ }245+ if !called {246+ t.Error("expected registered function to be called")247+ }248+ })249+250+ t.Run("Register overwrites", func(t *testing.T) {251+ first := false252+ second := false253+ Register("overwrite", func([]byte) (Gamer, error) {254+ first = true255+ return nil, nil256+ })257+ Register("overwrite", func([]byte) (Gamer, error) {258+ second = true259+ return nil, nil260+ })261+ if _, err := Registry["overwrite"](nil); err != nil {262+ t.Fatal(err)263+ }264+ if first {265+ t.Error("first function should not have been called")266+ }267+ if !second {268+ t.Error("second function should have been called")269+ }270+ })271+272+ t.Run("lookup missing key", func(t *testing.T) {273+ if Registry["nonexistent"] != nil {274+ t.Error("expected nil for missing key")275+ }276+ })277+}278+279+// --- TitleBarView (P3) ---280+281+func TestTitleBarView(t *testing.T) {282+ t.Run("unsolved game", func(t *testing.T) {283+ result := TitleBarView("Sudoku", "Easy", false)284+ if !strings.Contains(result, "Sudoku") {285+ t.Error("expected result to contain game name")286+ }287+ if !strings.Contains(result, "Easy") {288+ t.Error("expected result to contain mode name")289+ }290+ if strings.Contains(result, "SOLVED") {291+ t.Error("expected result to not contain SOLVED")292+ }293+ })294+295+ t.Run("solved game", func(t *testing.T) {296+ result := TitleBarView("Sudoku", "Easy", true)297+ if !strings.Contains(result, "SOLVED") {298+ t.Error("expected result to contain SOLVED")299+ }300+ })301+}302+303+// --- DebugHeader (P3) ---304+305+func TestDebugHeader(t *testing.T) {306+ t.Run("with rows", func(t *testing.T) {307+ rows := [][2]string{308+ {"Status", "In Progress"},309+ {"Size", "5x5"},310+ }311+ result := DebugHeader("TestGame", rows)312+ if !strings.Contains(result, "TestGame") {313+ t.Error("expected result to contain title")314+ }315+ if !strings.Contains(result, "Status") || !strings.Contains(result, "In Progress") {316+ t.Error("expected result to contain first key-value pair")317+ }318+ if !strings.Contains(result, "Size") || !strings.Contains(result, "5x5") {319+ t.Error("expected result to contain second key-value pair")320+ }321+ })322+323+ t.Run("empty rows", func(t *testing.T) {324+ result := DebugHeader("TestGame", nil)325+ if !strings.Contains(result, "TestGame") {326+ t.Error("expected result to contain title")327+ }328+ })329+}330+331+// --- DebugTable (P3) ---332+333+func TestDebugTable(t *testing.T) {334+ t.Run("normal table", func(t *testing.T) {335+ headers := []string{"Col", "Hint", "Current"}336+ rows := [][]string{337+ {"0", "1,2", "1,2"},338+ {"1", "3", "3"},339+ }340+ result := DebugTable("Tomography", headers, rows)341+ if !strings.Contains(result, "Tomography") {342+ t.Error("expected result to contain heading")343+ }344+ for _, h := range headers {345+ if !strings.Contains(result, h) {346+ t.Errorf("expected result to contain header %q", h)347+ }348+ }349+ if !strings.Contains(result, "1,2") {350+ t.Error("expected result to contain row values")351+ }352+ })353+354+ t.Run("empty rows", func(t *testing.T) {355+ headers := []string{"Col", "Hint"}356+ result := DebugTable("Heading", headers, nil)357+ if !strings.Contains(result, "Heading") {358+ t.Error("expected result to contain heading")359+ }360+ for _, h := range headers {361+ if !strings.Contains(result, h) {362+ t.Errorf("expected result to contain header %q", h)363+ }364+ }365+ })366+}
A hashiwokakero/hashiwokakero_test.go +994 -0
@@ -0,0 +1,994 @@
1+package hashiwokakero2+3+import (4+ "encoding/json"5+ "testing"6+)7+8+// --- helpers ---9+10+// fourCornerPuzzle builds a 4x4 puzzle with islands at the four corners:11+//12+// 0 . . 113+// . . . .14+// . . . .15+// 2 . . 316+func fourCornerPuzzle() *Puzzle {17+ return &Puzzle{18+ Width: 4,19+ Height: 4,20+ Islands: []Island{21+ {ID: 0, X: 0, Y: 0, Required: 2},22+ {ID: 1, X: 3, Y: 0, Required: 2},23+ {ID: 2, X: 0, Y: 3, Required: 2},24+ {ID: 3, X: 3, Y: 3, Required: 2},25+ },26+ }27+}28+29+// linePuzzle builds a horizontal line of islands: 0 -- 1 -- 2 -- 330+func linePuzzle() *Puzzle {31+ return &Puzzle{32+ Width: 7,33+ Height: 1,34+ Islands: []Island{35+ {ID: 0, X: 0, Y: 0, Required: 1},36+ {ID: 1, X: 2, Y: 0, Required: 2},37+ {ID: 2, X: 4, Y: 0, Required: 2},38+ {ID: 3, X: 6, Y: 0, Required: 1},39+ },40+ }41+}42+43+// --- GetBridge (P0) ---44+45+func TestGetBridge(t *testing.T) {46+ t.Run("bridge exists", func(t *testing.T) {47+ p := fourCornerPuzzle()48+ p.SetBridge(0, 1, 1)49+50+ b := p.GetBridge(0, 1)51+ if b == nil {52+ t.Fatal("expected bridge, got nil")53+ }54+ if b.Count != 1 {55+ t.Errorf("Count = %d, want 1", b.Count)56+ }57+ })58+59+ t.Run("order independence", func(t *testing.T) {60+ p := fourCornerPuzzle()61+ p.SetBridge(0, 1, 1)62+63+ b := p.GetBridge(1, 0)64+ if b == nil {65+ t.Fatal("GetBridge(1,0) should find bridge set as (0,1)")66+ }67+ if b.Count != 1 {68+ t.Errorf("Count = %d, want 1", b.Count)69+ }70+ })71+72+ t.Run("no bridge", func(t *testing.T) {73+ p := fourCornerPuzzle()74+ p.SetBridge(0, 1, 1)75+76+ if b := p.GetBridge(0, 2); b != nil {77+ t.Errorf("expected nil, got bridge with Count=%d", b.Count)78+ }79+ })80+}81+82+// --- SetBridge (P0) ---83+84+func TestSetBridge(t *testing.T) {85+ t.Run("create single bridge", func(t *testing.T) {86+ p := fourCornerPuzzle()87+ p.SetBridge(0, 1, 1)88+89+ b := p.GetBridge(0, 1)90+ if b == nil || b.Count != 1 {91+ t.Fatalf("expected Count=1, got %v", b)92+ }93+ })94+95+ t.Run("create double bridge", func(t *testing.T) {96+ p := fourCornerPuzzle()97+ p.SetBridge(0, 1, 2)98+99+ b := p.GetBridge(0, 1)100+ if b == nil || b.Count != 2 {101+ t.Fatalf("expected Count=2, got %v", b)102+ }103+ })104+105+ t.Run("update bridge", func(t *testing.T) {106+ p := fourCornerPuzzle()107+ p.SetBridge(0, 1, 1)108+ p.SetBridge(0, 1, 2)109+110+ b := p.GetBridge(0, 1)111+ if b == nil || b.Count != 2 {112+ t.Fatalf("expected Count=2 after update, got %v", b)113+ }114+ })115+116+ t.Run("remove bridge", func(t *testing.T) {117+ p := fourCornerPuzzle()118+ p.SetBridge(0, 1, 1)119+ p.SetBridge(0, 1, 0)120+121+ if b := p.GetBridge(0, 1); b != nil {122+ t.Errorf("expected bridge removed, got Count=%d", b.Count)123+ }124+ })125+126+ t.Run("remove nonexistent", func(t *testing.T) {127+ p := fourCornerPuzzle()128+ p.SetBridge(5, 6, 0) // must not panic129+ })130+131+ t.Run("normalizes order", func(t *testing.T) {132+ p := fourCornerPuzzle()133+ p.SetBridge(3, 1, 1)134+135+ if len(p.Bridges) != 1 {136+ t.Fatalf("expected 1 bridge, got %d", len(p.Bridges))137+ }138+ if p.Bridges[0].Island1 != 1 || p.Bridges[0].Island2 != 3 {139+ t.Errorf("bridge stored as (%d,%d), want (1,3)", p.Bridges[0].Island1, p.Bridges[0].Island2)140+ }141+ })142+143+ t.Run("invalidates caches", func(t *testing.T) {144+ p := fourCornerPuzzle()145+ // Trigger cache builds.146+ _ = p.BridgeCount(0)147+ _ = p.FindIslandAt(0, 0)148+ _ = p.CellContent(0, 0)149+150+ p.SetBridge(0, 1, 1)151+152+ if p.cellCache != nil {153+ t.Error("cellCache should be nil after SetBridge")154+ }155+ if p.posIndex != nil {156+ t.Error("posIndex should be nil after SetBridge")157+ }158+ if p.bridgeCounts != nil {159+ t.Error("bridgeCounts should be nil after SetBridge")160+ }161+ })162+}163+164+// --- BridgeCount (P0) ---165+166+func TestBridgeCount(t *testing.T) {167+ t.Run("single bridge", func(t *testing.T) {168+ p := fourCornerPuzzle()169+ p.SetBridge(0, 1, 1)170+171+ if got := p.BridgeCount(0); got != 1 {172+ t.Errorf("BridgeCount(0) = %d, want 1", got)173+ }174+ })175+176+ t.Run("double bridge", func(t *testing.T) {177+ p := fourCornerPuzzle()178+ p.SetBridge(0, 1, 2)179+180+ if got := p.BridgeCount(0); got != 2 {181+ t.Errorf("BridgeCount(0) = %d, want 2", got)182+ }183+ })184+185+ t.Run("multiple bridges", func(t *testing.T) {186+ p := fourCornerPuzzle()187+ p.SetBridge(0, 1, 1)188+ p.SetBridge(0, 2, 2)189+190+ if got := p.BridgeCount(0); got != 3 {191+ t.Errorf("BridgeCount(0) = %d, want 3", got)192+ }193+ })194+195+ t.Run("no bridges", func(t *testing.T) {196+ p := fourCornerPuzzle()197+198+ if got := p.BridgeCount(0); got != 0 {199+ t.Errorf("BridgeCount(0) = %d, want 0", got)200+ }201+ })202+203+ t.Run("invalid ID", func(t *testing.T) {204+ p := fourCornerPuzzle()205+206+ if got := p.BridgeCount(999); got != 0 {207+ t.Errorf("BridgeCount(999) = %d, want 0", got)208+ }209+ })210+211+ t.Run("counts both endpoints", func(t *testing.T) {212+ p := fourCornerPuzzle()213+ p.SetBridge(0, 1, 1)214+215+ if got := p.BridgeCount(1); got != 1 {216+ t.Errorf("BridgeCount(1) = %d, want 1", got)217+ }218+ })219+}220+221+// --- WouldCross (P0) ---222+223+func TestWouldCross(t *testing.T) {224+ t.Run("no crossing parallel horizontal", func(t *testing.T) {225+ // Two horizontal bridges on different rows can't cross.226+ // 0 --- 1227+ // 2 --- 3228+ p := fourCornerPuzzle()229+ p.SetBridge(0, 1, 1) // horizontal top230+ if p.WouldCross(2, 3) {231+ t.Error("parallel horizontal bridges should not cross")232+ }233+ })234+235+ t.Run("no crossing parallel vertical", func(t *testing.T) {236+ // Two vertical bridges on different columns.237+ p := fourCornerPuzzle()238+ p.SetBridge(0, 2, 1) // vertical left239+ if p.WouldCross(1, 3) {240+ t.Error("parallel vertical bridges should not cross")241+ }242+ })243+244+ t.Run("horizontal crosses vertical", func(t *testing.T) {245+ // Cross pattern: vertical bridge 0-2, then check horizontal 1-? crossing it.246+ // Need a puzzle with an island in the middle area.247+ p := &Puzzle{248+ Width: 5,249+ Height: 5,250+ Islands: []Island{251+ {ID: 0, X: 2, Y: 0, Required: 2},252+ {ID: 1, X: 0, Y: 2, Required: 2},253+ {ID: 2, X: 2, Y: 4, Required: 2},254+ {ID: 3, X: 4, Y: 2, Required: 2},255+ },256+ }257+ p.SetBridge(0, 2, 1) // vertical bridge through (2,1),(2,2),(2,3)258+ if !p.WouldCross(1, 3) {259+ t.Error("horizontal bridge should cross existing vertical bridge")260+ }261+ })262+263+ t.Run("vertical crosses horizontal", func(t *testing.T) {264+ p := &Puzzle{265+ Width: 5,266+ Height: 5,267+ Islands: []Island{268+ {ID: 0, X: 2, Y: 0, Required: 2},269+ {ID: 1, X: 0, Y: 2, Required: 2},270+ {ID: 2, X: 2, Y: 4, Required: 2},271+ {ID: 3, X: 4, Y: 2, Required: 2},272+ },273+ }274+ p.SetBridge(1, 3, 1) // horizontal bridge through (1,2),(2,2),(3,2)275+ if !p.WouldCross(0, 2) {276+ t.Error("vertical bridge should cross existing horizontal bridge")277+ }278+ })279+280+ t.Run("island in path", func(t *testing.T) {281+ // Island sitting between two endpoints blocks the bridge.282+ p := &Puzzle{283+ Width: 5,284+ Height: 1,285+ Islands: []Island{286+ {ID: 0, X: 0, Y: 0, Required: 2},287+ {ID: 1, X: 2, Y: 0, Required: 2},288+ {ID: 2, X: 4, Y: 0, Required: 2},289+ },290+ }291+ if !p.WouldCross(0, 2) {292+ t.Error("bridge should be blocked by island in the middle")293+ }294+ })295+296+ t.Run("endpoints dont count as crossing", func(t *testing.T) {297+ p := fourCornerPuzzle()298+ // No bridges set — the endpoints themselves are not obstacles.299+ if p.WouldCross(0, 1) {300+ t.Error("empty path between endpoints should not cross")301+ }302+ })303+304+ t.Run("non-aligned islands", func(t *testing.T) {305+ p := &Puzzle{306+ Width: 4,307+ Height: 4,308+ Islands: []Island{309+ {ID: 0, X: 0, Y: 0, Required: 1},310+ {ID: 1, X: 1, Y: 1, Required: 1},311+ },312+ }313+ if !p.WouldCross(0, 1) {314+ t.Error("diagonal (non-aligned) islands should return true")315+ }316+ })317+318+ t.Run("nil island", func(t *testing.T) {319+ p := fourCornerPuzzle()320+ if !p.WouldCross(0, 99) {321+ t.Error("bad island ID should return true")322+ }323+ })324+325+ t.Run("adjacent islands no obstacles", func(t *testing.T) {326+ p := &Puzzle{327+ Width: 3,328+ Height: 1,329+ Islands: []Island{330+ {ID: 0, X: 0, Y: 0, Required: 1},331+ {ID: 1, X: 1, Y: 0, Required: 1},332+ },333+ }334+ if p.WouldCross(0, 1) {335+ t.Error("adjacent islands with nothing between should not cross")336+ }337+ })338+}339+340+// --- IsConnected (P0) ---341+342+func TestIsConnected(t *testing.T) {343+ t.Run("all connected chain", func(t *testing.T) {344+ p := linePuzzle()345+ p.SetBridge(0, 1, 1)346+ p.SetBridge(1, 2, 1)347+ p.SetBridge(2, 3, 1)348+349+ if !p.IsConnected() {350+ t.Error("chain of bridges should be connected")351+ }352+ })353+354+ t.Run("disconnected pair", func(t *testing.T) {355+ p := fourCornerPuzzle()356+ p.SetBridge(0, 1, 1) // only top two connected357+358+ if p.IsConnected() {359+ t.Error("only two of four islands connected should not be connected")360+ }361+ })362+363+ t.Run("single island", func(t *testing.T) {364+ p := &Puzzle{365+ Width: 3,366+ Height: 3,367+ Islands: []Island{{ID: 0, X: 1, Y: 1, Required: 0}},368+ }369+ if !p.IsConnected() {370+ t.Error("single island should be connected")371+ }372+ })373+374+ t.Run("no bridges multiple islands", func(t *testing.T) {375+ p := fourCornerPuzzle()376+ if p.IsConnected() {377+ t.Error("multiple islands with no bridges should not be connected")378+ }379+ })380+381+ t.Run("multiple components", func(t *testing.T) {382+ p := fourCornerPuzzle()383+ p.SetBridge(0, 1, 1) // top pair384+ p.SetBridge(2, 3, 1) // bottom pair385+386+ if p.IsConnected() {387+ t.Error("two separate pairs should not be connected")388+ }389+ })390+391+ t.Run("star topology", func(t *testing.T) {392+ // Center island connected to 4 spokes.393+ p := &Puzzle{394+ Width: 5,395+ Height: 5,396+ Islands: []Island{397+ {ID: 0, X: 2, Y: 2, Required: 4},398+ {ID: 1, X: 2, Y: 0, Required: 1},399+ {ID: 2, X: 4, Y: 2, Required: 1},400+ {ID: 3, X: 2, Y: 4, Required: 1},401+ {ID: 4, X: 0, Y: 2, Required: 1},402+ },403+ }404+ p.SetBridge(0, 1, 1)405+ p.SetBridge(0, 2, 1)406+ p.SetBridge(0, 3, 1)407+ p.SetBridge(0, 4, 1)408+409+ if !p.IsConnected() {410+ t.Error("star topology should be connected")411+ }412+ })413+}414+415+// --- IsSolved (P0) ---416+417+func TestIsSolved(t *testing.T) {418+ t.Run("fully solved", func(t *testing.T) {419+ // Simple: two islands requiring 1 bridge each.420+ p := &Puzzle{421+ Width: 3,422+ Height: 1,423+ Islands: []Island{424+ {ID: 0, X: 0, Y: 0, Required: 1},425+ {ID: 1, X: 2, Y: 0, Required: 1},426+ },427+ }428+ p.SetBridge(0, 1, 1)429+430+ if !p.IsSolved() {431+ t.Error("puzzle should be solved")432+ }433+ })434+435+ t.Run("satisfied but disconnected", func(t *testing.T) {436+ // Four islands: each requires 1 bridge. Connect as two pairs.437+ p := &Puzzle{438+ Width: 5,439+ Height: 5,440+ Islands: []Island{441+ {ID: 0, X: 0, Y: 0, Required: 1},442+ {ID: 1, X: 2, Y: 0, Required: 1},443+ {ID: 2, X: 0, Y: 4, Required: 1},444+ {ID: 3, X: 2, Y: 4, Required: 1},445+ },446+ }447+ p.SetBridge(0, 1, 1)448+ p.SetBridge(2, 3, 1)449+450+ if p.IsSolved() {451+ t.Error("satisfied but disconnected should not be solved")452+ }453+ })454+455+ t.Run("connected but unsatisfied", func(t *testing.T) {456+ p := &Puzzle{457+ Width: 3,458+ Height: 1,459+ Islands: []Island{460+ {ID: 0, X: 0, Y: 0, Required: 2},461+ {ID: 1, X: 2, Y: 0, Required: 2},462+ },463+ }464+ p.SetBridge(0, 1, 1) // only 1, need 2465+466+ if p.IsSolved() {467+ t.Error("connected but unsatisfied should not be solved")468+ }469+ })470+471+ t.Run("over-satisfied", func(t *testing.T) {472+ p := &Puzzle{473+ Width: 3,474+ Height: 1,475+ Islands: []Island{476+ {ID: 0, X: 0, Y: 0, Required: 1},477+ {ID: 1, X: 2, Y: 0, Required: 1},478+ },479+ }480+ p.SetBridge(0, 1, 2)481+482+ if p.IsSolved() {483+ t.Error("over-satisfied should not be solved")484+ }485+ })486+487+ t.Run("empty puzzle no bridges", func(t *testing.T) {488+ p := fourCornerPuzzle()489+ if p.IsSolved() {490+ t.Error("no bridges placed should not be solved")491+ }492+ })493+}494+495+// --- FindIslandAt (P1) ---496+497+func TestFindIslandAt(t *testing.T) {498+ t.Run("island exists", func(t *testing.T) {499+ p := fourCornerPuzzle()500+ isl := p.FindIslandAt(3, 0)501+ if isl == nil {502+ t.Fatal("expected island at (3,0)")503+ }504+ if isl.ID != 1 {505+ t.Errorf("ID = %d, want 1", isl.ID)506+ }507+ })508+509+ t.Run("no island", func(t *testing.T) {510+ p := fourCornerPuzzle()511+ if isl := p.FindIslandAt(1, 1); isl != nil {512+ t.Errorf("expected nil at (1,1), got island %d", isl.ID)513+ }514+ })515+516+ t.Run("lazy index build", func(t *testing.T) {517+ p := fourCornerPuzzle()518+ if p.posIndex != nil {519+ t.Fatal("posIndex should be nil before first call")520+ }521+ _ = p.FindIslandAt(0, 0)522+ if p.posIndex == nil {523+ t.Error("posIndex should be built after first call")524+ }525+ // Subsequent call should use the same index.526+ _ = p.FindIslandAt(3, 3)527+ if p.posIndex == nil {528+ t.Error("posIndex should persist across calls")529+ }530+ })531+}532+533+// --- FindIslandByID (P1) ---534+535+func TestFindIslandByID(t *testing.T) {536+ t.Run("sequential IDs", func(t *testing.T) {537+ p := fourCornerPuzzle()538+ isl := p.FindIslandByID(2)539+ if isl == nil {540+ t.Fatal("expected island with ID=2")541+ }542+ if isl.X != 0 || isl.Y != 3 {543+ t.Errorf("island 2 at (%d,%d), want (0,3)", isl.X, isl.Y)544+ }545+ })546+547+ t.Run("invalid ID", func(t *testing.T) {548+ p := fourCornerPuzzle()549+ if isl := p.FindIslandByID(99); isl != nil {550+ t.Errorf("expected nil, got island %d", isl.ID)551+ }552+ })553+554+ t.Run("non-sequential fallback", func(t *testing.T) {555+ p := &Puzzle{556+ Width: 3,557+ Height: 3,558+ Islands: []Island{559+ {ID: 10, X: 0, Y: 0, Required: 1},560+ {ID: 20, X: 2, Y: 0, Required: 1},561+ {ID: 30, X: 0, Y: 2, Required: 1},562+ },563+ }564+ isl := p.FindIslandByID(20)565+ if isl == nil {566+ t.Fatal("expected island with ID=20")567+ }568+ if isl.X != 2 || isl.Y != 0 {569+ t.Errorf("island 20 at (%d,%d), want (2,0)", isl.X, isl.Y)570+ }571+ })572+}573+574+// --- FindAdjacentIsland (P1) ---575+576+func TestFindAdjacentIsland(t *testing.T) {577+ t.Run("finds island to the right", func(t *testing.T) {578+ p := fourCornerPuzzle()579+ adj := p.FindAdjacentIsland(0, 1, 0)580+ if adj == nil || adj.ID != 1 {581+ t.Errorf("expected island 1, got %v", adj)582+ }583+ })584+585+ t.Run("finds island below", func(t *testing.T) {586+ p := fourCornerPuzzle()587+ adj := p.FindAdjacentIsland(0, 0, 1)588+ if adj == nil || adj.ID != 2 {589+ t.Errorf("expected island 2, got %v", adj)590+ }591+ })592+593+ t.Run("no island in direction", func(t *testing.T) {594+ p := fourCornerPuzzle()595+ adj := p.FindAdjacentIsland(0, -1, 0) // left from leftmost596+ if adj != nil {597+ t.Errorf("expected nil, got island %d", adj.ID)598+ }599+ })600+601+ t.Run("stops at grid edge", func(t *testing.T) {602+ p := fourCornerPuzzle()603+ adj := p.FindAdjacentIsland(0, 0, -1) // up from top604+ if adj != nil {605+ t.Errorf("expected nil, got island %d", adj.ID)606+ }607+ })608+609+ t.Run("blocked by perpendicular bridge", func(t *testing.T) {610+ // Cross puzzle:611+ // . 0 .612+ // 1 . 2613+ // . 3 .614+ p := &Puzzle{615+ Width: 3,616+ Height: 3,617+ Islands: []Island{618+ {ID: 0, X: 1, Y: 0, Required: 1},619+ {ID: 1, X: 0, Y: 1, Required: 1},620+ {ID: 2, X: 2, Y: 1, Required: 1},621+ {ID: 3, X: 1, Y: 2, Required: 1},622+ },623+ }624+ p.SetBridge(1, 2, 1) // horizontal bridge through (1,1)625+626+ // Vertical ray from 0 downward should be blocked at (1,1).627+ adj := p.FindAdjacentIsland(0, 0, 1)628+ if adj != nil {629+ t.Errorf("expected nil (blocked by H bridge), got island %d", adj.ID)630+ }631+ })632+633+ t.Run("not blocked by parallel bridge", func(t *testing.T) {634+ // Two islands vertically aligned with a vertical bridge between another pair635+ // in the same column. The ray should pass through a parallel bridge.636+ p := &Puzzle{637+ Width: 3,638+ Height: 5,639+ Islands: []Island{640+ {ID: 0, X: 0, Y: 0, Required: 1},641+ {ID: 1, X: 0, Y: 2, Required: 2},642+ {ID: 2, X: 0, Y: 4, Required: 1},643+ },644+ }645+ // Bridge 1-2 creates vertical bridge segments at (0,3).646+ p.SetBridge(1, 2, 1)647+648+ // Ray from 0 downward should find island 1 (not blocked by vertical bridge beyond).649+ adj := p.FindAdjacentIsland(0, 0, 1)650+ if adj == nil || adj.ID != 1 {651+ t.Errorf("expected island 1, got %v", adj)652+ }653+ })654+}655+656+// --- CellContent (P1) ---657+658+func TestCellContent(t *testing.T) {659+ t.Run("island cell", func(t *testing.T) {660+ p := fourCornerPuzzle()661+ ci := p.CellContent(0, 0)662+ if ci.Kind != cellIsland {663+ t.Errorf("Kind = %d, want cellIsland(%d)", ci.Kind, cellIsland)664+ }665+ if ci.IslandID != 0 {666+ t.Errorf("IslandID = %d, want 0", ci.IslandID)667+ }668+ })669+670+ t.Run("horizontal bridge cell", func(t *testing.T) {671+ p := fourCornerPuzzle()672+ p.SetBridge(0, 1, 1)673+674+ ci := p.CellContent(1, 0)675+ if ci.Kind != cellBridgeH {676+ t.Errorf("Kind = %d, want cellBridgeH(%d)", ci.Kind, cellBridgeH)677+ }678+ })679+680+ t.Run("vertical bridge cell", func(t *testing.T) {681+ p := fourCornerPuzzle()682+ p.SetBridge(0, 2, 1)683+684+ ci := p.CellContent(0, 1)685+ if ci.Kind != cellBridgeV {686+ t.Errorf("Kind = %d, want cellBridgeV(%d)", ci.Kind, cellBridgeV)687+ }688+ })689+690+ t.Run("empty cell", func(t *testing.T) {691+ p := fourCornerPuzzle()692+ ci := p.CellContent(1, 1)693+ if ci.Kind != cellEmpty {694+ t.Errorf("Kind = %d, want cellEmpty(%d)", ci.Kind, cellEmpty)695+ }696+ })697+698+ t.Run("bridge count", func(t *testing.T) {699+ p := fourCornerPuzzle()700+ p.SetBridge(0, 1, 2)701+702+ ci := p.CellContent(1, 0) // bridge segment between (0,0) and (3,0)703+ if ci.BridgeCount != 2 {704+ t.Errorf("BridgeCount = %d, want 2", ci.BridgeCount)705+ }706+ })707+708+ t.Run("lazy rebuild after modification", func(t *testing.T) {709+ p := fourCornerPuzzle()710+ _ = p.CellContent(1, 0) // build cache (empty)711+712+ p.SetBridge(0, 1, 1) // invalidates cache713+714+ ci := p.CellContent(1, 0) // should rebuild715+ if ci.Kind != cellBridgeH {716+ t.Errorf("Kind = %d after rebuild, want cellBridgeH(%d)", ci.Kind, cellBridgeH)717+ }718+ })719+}720+721+// --- Save/Load round-trip (P1) ---722+723+func TestSaveLoadRoundTrip(t *testing.T) {724+ t.Run("full round trip", func(t *testing.T) {725+ original := &Model{726+ puzzle: Puzzle{727+ Width: 7,728+ Height: 7,729+ Islands: []Island{730+ {ID: 0, X: 0, Y: 0, Required: 2},731+ {ID: 1, X: 3, Y: 0, Required: 3},732+ {ID: 2, X: 6, Y: 0, Required: 1},733+ {ID: 3, X: 0, Y: 3, Required: 2},734+ },735+ Bridges: []Bridge{736+ {Island1: 0, Island2: 1, Count: 1},737+ {Island1: 1, Island2: 2, Count: 2},738+ {Island1: 0, Island2: 3, Count: 1},739+ },740+ },741+ cursorIsland: 1,742+ keys: DefaultKeyMap,743+ }744+745+ data, err := original.GetSave()746+ if err != nil {747+ t.Fatal(err)748+ }749+750+ got, err := ImportModel(data)751+ if err != nil {752+ t.Fatal(err)753+ }754+755+ // Dimensions.756+ if got.puzzle.Width != 7 || got.puzzle.Height != 7 {757+ t.Errorf("dimensions = %dx%d, want 7x7", got.puzzle.Width, got.puzzle.Height)758+ }759+760+ // Islands.761+ if len(got.puzzle.Islands) != 4 {762+ t.Fatalf("island count = %d, want 4", len(got.puzzle.Islands))763+ }764+ for i, isl := range original.puzzle.Islands {765+ g := got.puzzle.Islands[i]766+ if g.ID != isl.ID || g.X != isl.X || g.Y != isl.Y || g.Required != isl.Required {767+ t.Errorf("island[%d] = %+v, want %+v", i, g, isl)768+ }769+ }770+771+ // Bridges.772+ if len(got.puzzle.Bridges) != 3 {773+ t.Fatalf("bridge count = %d, want 3", len(got.puzzle.Bridges))774+ }775+ for i, b := range original.puzzle.Bridges {776+ g := got.puzzle.Bridges[i]777+ if g.Island1 != b.Island1 || g.Island2 != b.Island2 || g.Count != b.Count {778+ t.Errorf("bridge[%d] = %+v, want %+v", i, g, b)779+ }780+ }781+ })782+783+ t.Run("caches rebuilt on access", func(t *testing.T) {784+ m := &Model{785+ puzzle: Puzzle{786+ Width: 3,787+ Height: 1,788+ Islands: []Island{789+ {ID: 0, X: 0, Y: 0, Required: 1},790+ {ID: 1, X: 2, Y: 0, Required: 1},791+ },792+ Bridges: []Bridge{793+ {Island1: 0, Island2: 1, Count: 1},794+ },795+ },796+ keys: DefaultKeyMap,797+ }798+799+ data, err := m.GetSave()800+ if err != nil {801+ t.Fatal(err)802+ }803+804+ got, err := ImportModel(data)805+ if err != nil {806+ t.Fatal(err)807+ }808+809+ // Caches should be nil after import; accessing BridgeCount triggers rebuild.810+ if got.puzzle.bridgeCounts != nil {811+ t.Error("bridgeCounts should be nil immediately after import")812+ }813+ if cnt := got.puzzle.BridgeCount(0); cnt != 1 {814+ t.Errorf("BridgeCount(0) = %d, want 1", cnt)815+ }816+ })817+818+ t.Run("invalid JSON", func(t *testing.T) {819+ _, err := ImportModel([]byte("not json"))820+ if err == nil {821+ t.Fatal("expected error for invalid JSON")822+ }823+ })824+}825+826+// --- placeIslands (P2) ---827+828+func TestPlaceIslands(t *testing.T) {829+ t.Run("places correct count", func(t *testing.T) {830+ for range 10 {831+ islands := placeIslands(7, 7, 6)832+ if islands == nil {833+ continue // may fail on rare occasions834+ }835+ if len(islands) != 6 {836+ t.Errorf("len(islands) = %d, want 6", len(islands))837+ }838+ }839+ })840+841+ t.Run("no diagonal adjacency", func(t *testing.T) {842+ for range 20 {843+ islands := placeIslands(7, 7, 6)844+ if islands == nil {845+ continue846+ }847+ for i := 0; i < len(islands); i++ {848+ for j := i + 1; j < len(islands); j++ {849+ dx := abs(islands[i].X - islands[j].X)850+ dy := abs(islands[i].Y - islands[j].Y)851+ if dx <= 1 && dy <= 1 && dx > 0 && dy > 0 {852+ t.Errorf("islands %d(%d,%d) and %d(%d,%d) are diagonally adjacent",853+ islands[i].ID, islands[i].X, islands[i].Y,854+ islands[j].ID, islands[j].X, islands[j].Y)855+ }856+ }857+ }858+ }859+ })860+861+ t.Run("returns nil when impossible", func(t *testing.T) {862+ islands := placeIslands(3, 3, 100)863+ if islands != nil {864+ t.Errorf("expected nil for 100 islands on 3x3, got %d islands", len(islands))865+ }866+ })867+868+ t.Run("all within bounds", func(t *testing.T) {869+ for range 20 {870+ islands := placeIslands(7, 7, 6)871+ if islands == nil {872+ continue873+ }874+ for _, isl := range islands {875+ if isl.X < 0 || isl.X >= 7 || isl.Y < 0 || isl.Y >= 7 {876+ t.Errorf("island %d at (%d,%d) out of bounds", isl.ID, isl.X, isl.Y)877+ }878+ }879+ }880+ })881+}882+883+// --- isDirectlyConnectable (P2) ---884+885+func TestIsDirectlyConnectable(t *testing.T) {886+ t.Run("aligned no obstacle", func(t *testing.T) {887+ p := &Puzzle{888+ Width: 5,889+ Height: 1,890+ Islands: []Island{891+ {ID: 0, X: 0, Y: 0, Required: 1},892+ {ID: 1, X: 4, Y: 0, Required: 1},893+ },894+ }895+ if !isDirectlyConnectable(p, p.Islands[0], p.Islands[1]) {896+ t.Error("aligned islands with no obstacle should be connectable")897+ }898+ })899+900+ t.Run("aligned island between", func(t *testing.T) {901+ p := linePuzzle()902+ // Islands 0,1,2,3 at x=0,2,4,6. Check if 0 and 2 are directly connectable903+ // (island 1 at x=2 sits between them... actually 1 is at the endpoint).904+ // Instead: 0 at x=0 and 3 at x=6 — islands 1(x=2) and 2(x=4) are between.905+ if isDirectlyConnectable(p, p.Islands[0], p.Islands[3]) {906+ t.Error("island between should block direct connection")907+ }908+ })909+910+ t.Run("not aligned", func(t *testing.T) {911+ p := fourCornerPuzzle()912+ // Islands 0(0,0) and 3(3,3) are diagonal — not aligned.913+ if isDirectlyConnectable(p, p.Islands[0], p.Islands[3]) {914+ t.Error("diagonal pair should not be connectable")915+ }916+ })917+918+ t.Run("adjacent distance 1", func(t *testing.T) {919+ p := &Puzzle{920+ Width: 2,921+ Height: 1,922+ Islands: []Island{923+ {ID: 0, X: 0, Y: 0, Required: 1},924+ {ID: 1, X: 1, Y: 0, Required: 1},925+ },926+ }927+ if !isDirectlyConnectable(p, p.Islands[0], p.Islands[1]) {928+ t.Error("adjacent islands should be connectable")929+ }930+ })931+}932+933+// --- GeneratePuzzle (P2) ---934+935+func TestGeneratePuzzle(t *testing.T) {936+ mode := NewMode("Test", "test mode", 7, 7, 8, 10)937+938+ t.Run("produces valid puzzle", func(t *testing.T) {939+ puzzle := GeneratePuzzle(mode)940+ for _, isl := range puzzle.Islands {941+ if isl.Required < 1 {942+ t.Errorf("island %d has Required=%d, want >= 1", isl.ID, isl.Required)943+ }944+ }945+ })946+947+ t.Run("bridges cleared", func(t *testing.T) {948+ puzzle := GeneratePuzzle(mode)949+ if len(puzzle.Bridges) != 0 {950+ t.Errorf("len(Bridges) = %d, want 0", len(puzzle.Bridges))951+ }952+ })953+954+ t.Run("correct dimensions", func(t *testing.T) {955+ puzzle := GeneratePuzzle(mode)956+ if puzzle.Width != 7 || puzzle.Height != 7 {957+ t.Errorf("dimensions = %dx%d, want 7x7", puzzle.Width, puzzle.Height)958+ }959+ })960+}961+962+// --- Save JSON structure (P1) ---963+964+func TestSaveJSON(t *testing.T) {965+ m := Model{966+ puzzle: Puzzle{967+ Width: 4,968+ Height: 4,969+ Islands: []Island{970+ {ID: 0, X: 0, Y: 0, Required: 2},971+ },972+ Bridges: []Bridge{973+ {Island1: 0, Island2: 1, Count: 1},974+ },975+ },976+ keys: DefaultKeyMap,977+ }978+979+ data, err := m.GetSave()980+ if err != nil {981+ t.Fatal(err)982+ }983+984+ var raw map[string]json.RawMessage985+ if err := json.Unmarshal(data, &raw); err != nil {986+ t.Fatal(err)987+ }988+989+ for _, key := range []string{"width", "height", "islands", "bridges"} {990+ if _, ok := raw[key]; !ok {991+ t.Errorf("missing key %q in save JSON", key)992+ }993+ }994+}
A nonogram/nonogram_test.go +582 -0
@@ -0,0 +1,582 @@
1+package nonogram2+3+import (4+ "reflect"5+ "strings"6+ "testing"7+8+ "github.com/FelineStateMachine/puzzletea/game"9+)10+11+// --- generateTomography (P0) ---12+13+func TestGenerateTomography(t *testing.T) {14+ tests := []struct {15+ name string16+ grid grid17+ wantRows TomographyDefinition18+ wantCols TomographyDefinition19+ }{20+ {21+ name: "all filled row",22+ grid: grid{[]rune{filledTile, filledTile, filledTile}},23+ wantRows: TomographyDefinition{{3}},24+ wantCols: TomographyDefinition{{1}, {1}, {1}},25+ },26+ {27+ name: "all empty row",28+ grid: grid{[]rune{emptyTile, emptyTile, emptyTile}},29+ wantRows: TomographyDefinition{{0}},30+ wantCols: TomographyDefinition{{0}, {0}, {0}},31+ },32+ {33+ name: "single block",34+ grid: grid{[]rune{filledTile, filledTile, emptyTile, emptyTile}},35+ wantRows: TomographyDefinition{{2}},36+ wantCols: TomographyDefinition{{1}, {1}, {0}, {0}},37+ },38+ {39+ name: "two blocks",40+ grid: grid{[]rune{filledTile, filledTile, emptyTile, filledTile}},41+ wantRows: TomographyDefinition{{2, 1}},42+ wantCols: TomographyDefinition{{1}, {1}, {0}, {1}},43+ },44+ {45+ name: "alternating",46+ grid: grid{[]rune{filledTile, emptyTile, filledTile, emptyTile, filledTile}},47+ wantRows: TomographyDefinition{{1, 1, 1}},48+ wantCols: TomographyDefinition{{1}, {0}, {1}, {0}, {1}},49+ },50+ {51+ name: "column hints vertical pattern",52+ grid: grid{53+ []rune{filledTile, emptyTile},54+ []rune{filledTile, emptyTile},55+ []rune{emptyTile, filledTile},56+ },57+ wantRows: TomographyDefinition{{1}, {1}, {1}},58+ wantCols: TomographyDefinition{{2}, {1}},59+ },60+ {61+ name: "1x1 filled",62+ grid: grid{[]rune{filledTile}},63+ wantRows: TomographyDefinition{{1}},64+ wantCols: TomographyDefinition{{1}},65+ },66+ {67+ name: "1x1 empty",68+ grid: grid{[]rune{emptyTile}},69+ wantRows: TomographyDefinition{{0}},70+ wantCols: TomographyDefinition{{0}},71+ },72+ {73+ name: "empty grid",74+ grid: grid{},75+ wantRows: TomographyDefinition{},76+ wantCols: TomographyDefinition{},77+ },78+ {79+ name: "rectangular grid 3x5",80+ grid: grid{81+ []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},82+ []rune{emptyTile, emptyTile, emptyTile, emptyTile, emptyTile},83+ []rune{filledTile, filledTile, filledTile, filledTile, filledTile},84+ },85+ wantRows: TomographyDefinition{{1, 1, 1}, {0}, {5}},86+ wantCols: TomographyDefinition{{1, 1}, {1}, {1, 1}, {1}, {1, 1}},87+ },88+ }89+90+ for _, tt := range tests {91+ t.Run(tt.name, func(t *testing.T) {92+ hints := generateTomography(tt.grid)93+ if !reflect.DeepEqual([][]int(hints.rows), [][]int(tt.wantRows)) {94+ t.Errorf("rows = %v, want %v", hints.rows, tt.wantRows)95+ }96+ if !reflect.DeepEqual([][]int(hints.cols), [][]int(tt.wantCols)) {97+ t.Errorf("cols = %v, want %v", hints.cols, tt.wantCols)98+ }99+ })100+ }101+}102+103+// --- TomographyDefinition.equal (P0) ---104+105+func TestTomographyDefinitionEqual(t *testing.T) {106+ tests := []struct {107+ name string108+ a TomographyDefinition109+ b TomographyDefinition110+ want bool111+ }{112+ {113+ name: "identical single hints",114+ a: TomographyDefinition{{1, 2}, {3}},115+ b: TomographyDefinition{{1, 2}, {3}},116+ want: true,117+ },118+ {119+ name: "different values",120+ a: TomographyDefinition{{1, 2}},121+ b: TomographyDefinition{{1, 3}},122+ want: false,123+ },124+ {125+ name: "different inner lengths",126+ a: TomographyDefinition{{1, 2}},127+ b: TomographyDefinition{{1, 2, 3}},128+ want: false,129+ },130+ {131+ name: "different outer lengths",132+ a: TomographyDefinition{{1}, {2}},133+ b: TomographyDefinition{{1}},134+ want: false,135+ },136+ {137+ name: "both empty",138+ a: TomographyDefinition{},139+ b: TomographyDefinition{},140+ want: true,141+ },142+ {143+ name: "nil vs empty",144+ a: nil,145+ b: TomographyDefinition{},146+ want: true,147+ },148+ }149+150+ for _, tt := range tests {151+ t.Run(tt.name, func(t *testing.T) {152+ if got := tt.a.equal(tt.b); got != tt.want {153+ t.Errorf("equal() = %v, want %v", got, tt.want)154+ }155+ })156+ }157+}158+159+// --- TomographyDefinition.RequiredLen (P1) ---160+161+func TestTomographyDefinitionRequiredLen(t *testing.T) {162+ tests := []struct {163+ name string164+ td TomographyDefinition165+ want int166+ }{167+ {"varying lengths", TomographyDefinition{{1}, {2, 3}, {4}}, 2},168+ {"single element each", TomographyDefinition{{5}, {3}}, 1},169+ {"empty definition", TomographyDefinition{}, 0},170+ }171+172+ for _, tt := range tests {173+ t.Run(tt.name, func(t *testing.T) {174+ if got := tt.td.RequiredLen(); got != tt.want {175+ t.Errorf("RequiredLen() = %d, want %d", got, tt.want)176+ }177+ })178+ }179+}180+181+// --- Grid serialization round-trip (P1) ---182+183+func TestGridRoundTrip(t *testing.T) {184+ t.Run("5x5 round-trip", func(t *testing.T) {185+ g := grid{186+ []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},187+ []rune{emptyTile, filledTile, emptyTile, filledTile, emptyTile},188+ []rune{filledTile, filledTile, filledTile, filledTile, filledTile},189+ []rune{emptyTile, emptyTile, emptyTile, emptyTile, emptyTile},190+ []rune{filledTile, emptyTile, emptyTile, emptyTile, filledTile},191+ }192+ s := g.String()193+ g2 := newGrid(state(s))194+ s2 := g2.String()195+ if s != s2 {196+ t.Errorf("round-trip mismatch:\n got: %q\n want: %q", s2, s)197+ }198+ })199+200+ t.Run("preserves all tile types", func(t *testing.T) {201+ g := grid{202+ []rune{filledTile, markedTile, emptyTile},203+ }204+ s := g.String()205+ g2 := newGrid(state(s))206+ s2 := g2.String()207+ if s != s2 {208+ t.Errorf("tile types not preserved:\n got: %q\n want: %q", s2, s)209+ }210+ })211+212+ t.Run("single row", func(t *testing.T) {213+ g := grid{214+ []rune{filledTile, emptyTile, filledTile},215+ }216+ s := g.String()217+ g2 := newGrid(state(s))218+ if len(g2) != 1 {219+ t.Fatalf("expected 1 row, got %d", len(g2))220+ }221+ if g2.String() != s {222+ t.Error("single row round-trip mismatch")223+ }224+ })225+226+ t.Run("single column", func(t *testing.T) {227+ g := grid{228+ []rune{filledTile},229+ []rune{emptyTile},230+ []rune{filledTile},231+ }232+ s := g.String()233+ g2 := newGrid(state(s))234+ if len(g2) != 3 {235+ t.Fatalf("expected 3 rows, got %d", len(g2))236+ }237+ if g2.String() != s {238+ t.Error("single column round-trip mismatch")239+ }240+ })241+}242+243+// --- createEmptyState (P1) ---244+245+func TestCreateEmptyState(t *testing.T) {246+ t.Run("5x5", func(t *testing.T) {247+ s := createEmptyState(5, 5)248+ rows := strings.Split(string(s), "\n")249+ if len(rows) != 5 {250+ t.Fatalf("expected 5 rows, got %d", len(rows))251+ }252+ for i, row := range rows {253+ if len([]rune(row)) != 5 {254+ t.Errorf("row %d: expected 5 runes, got %d", i, len([]rune(row)))255+ }256+ for _, r := range row {257+ if r != emptyTile {258+ t.Errorf("row %d: expected emptyTile, got %q", i, r)259+ }260+ }261+ }262+ })263+264+ t.Run("1x1", func(t *testing.T) {265+ s := createEmptyState(1, 1)266+ if string(s) != string(emptyTile) {267+ t.Errorf("expected single emptyTile, got %q", s)268+ }269+ if strings.Contains(string(s), "\n") {270+ t.Error("1x1 should not contain newline")271+ }272+ })273+274+ t.Run("0 dimension", func(t *testing.T) {275+ if s := createEmptyState(0, 5); s != "" {276+ t.Errorf("expected empty string for h=0, got %q", s)277+ }278+ if s := createEmptyState(5, 0); s != "" {279+ t.Errorf("expected empty string for w=0, got %q", s)280+ }281+ })282+283+ t.Run("negative dimension", func(t *testing.T) {284+ if s := createEmptyState(-1, 5); s != "" {285+ t.Errorf("expected empty string for h=-1, got %q", s)286+ }287+ })288+}289+290+// --- generateRandomState (P2) ---291+292+func TestGenerateRandomState(t *testing.T) {293+ t.Run("density clamping low", func(t *testing.T) {294+ // Use a larger grid and multiple runs to avoid flakiness from per-row jitter.295+ totalFilled := 0296+ for range 10 {297+ s := generateRandomState(10, 10, 0.0)298+ g := newGrid(s)299+ for _, row := range g {300+ for _, r := range row {301+ if r == filledTile {302+ totalFilled++303+ }304+ }305+ }306+ }307+ if totalFilled == 0 {308+ t.Error("expected some filled cells with density clamped from 0.0")309+ }310+ })311+312+ t.Run("density clamping high", func(t *testing.T) {313+ totalEmpty := 0314+ for range 10 {315+ s := generateRandomState(10, 10, 1.0)316+ g := newGrid(s)317+ for _, row := range g {318+ for _, r := range row {319+ if r == emptyTile {320+ totalEmpty++321+ }322+ }323+ }324+ }325+ if totalEmpty == 0 {326+ t.Error("expected some empty cells with density clamped from 1.0")327+ }328+ })329+330+ t.Run("correct dimensions", func(t *testing.T) {331+ s := generateRandomState(5, 10, 0.5)332+ g := newGrid(s)333+ if len(g) != 5 {334+ t.Fatalf("expected 5 rows, got %d", len(g))335+ }336+ for i, row := range g {337+ if len(row) != 10 {338+ t.Errorf("row %d: expected 10 cols, got %d", i, len(row))339+ }340+ }341+ })342+343+ t.Run("0 dimensions", func(t *testing.T) {344+ if s := generateRandomState(0, 5, 0.5); s != "" {345+ t.Errorf("expected empty string for h=0, got %q", s)346+ }347+ if s := generateRandomState(5, 0, 0.5); s != "" {348+ t.Errorf("expected empty string for w=0, got %q", s)349+ }350+ })351+352+ t.Run("approximate density", func(t *testing.T) {353+ totalFilled := 0354+ totalCells := 0355+ runs := 20356+ for range runs {357+ s := generateRandomState(10, 10, 0.5)358+ g := newGrid(s)359+ for _, row := range g {360+ for _, r := range row {361+ totalCells++362+ if r == filledTile {363+ totalFilled++364+ }365+ }366+ }367+ }368+ ratio := float64(totalFilled) / float64(totalCells)369+ if ratio < 0.2 || ratio > 0.8 {370+ t.Errorf("average density = %.2f, expected roughly 0.5 (within 0.2–0.8)", ratio)371+ }372+ })373+}374+375+// --- isValidPuzzle (P0) ---376+377+func TestIsValidPuzzle(t *testing.T) {378+ tests := []struct {379+ name string380+ hints Hints381+ height int382+ width int383+ want bool384+ }{385+ {386+ name: "all-zero row",387+ hints: Hints{388+ rows: TomographyDefinition{{0}, {1, 2}},389+ cols: TomographyDefinition{{1}, {2}},390+ },391+ height: 2, width: 2,392+ want: false,393+ },394+ {395+ name: "full-width row",396+ hints: Hints{397+ rows: TomographyDefinition{{5}, {1, 2}},398+ cols: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},399+ },400+ height: 2, width: 5,401+ want: false,402+ },403+ {404+ name: "all-zero column",405+ hints: Hints{406+ rows: TomographyDefinition{{1}, {1}},407+ cols: TomographyDefinition{{0}, {2}},408+ },409+ height: 2, width: 2,410+ want: false,411+ },412+ {413+ name: "full-height column",414+ hints: Hints{415+ rows: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},416+ cols: TomographyDefinition{{5}, {1, 2}},417+ },418+ height: 5, width: 2,419+ want: false,420+ },421+ {422+ name: "valid mixed hints",423+ hints: Hints{424+ rows: TomographyDefinition{{1, 1}, {2}},425+ cols: TomographyDefinition{{1}, {1, 1}, {1}},426+ },427+ height: 2, width: 3,428+ want: true,429+ },430+ {431+ name: "multi-segment rows valid",432+ hints: Hints{433+ rows: TomographyDefinition{{2, 1}, {1, 1}},434+ cols: TomographyDefinition{{1}, {1, 1}, {1}, {1}, {1}},435+ },436+ height: 2, width: 5,437+ want: true,438+ },439+ }440+441+ for _, tt := range tests {442+ t.Run(tt.name, func(t *testing.T) {443+ if got := isValidPuzzle(tt.hints, tt.height, tt.width); got != tt.want {444+ t.Errorf("isValidPuzzle() = %v, want %v", got, tt.want)445+ }446+ })447+ }448+}449+450+// --- GenerateRandomTomography (P2) ---451+452+func TestGenerateRandomTomography(t *testing.T) {453+ mode := NewMode("Test", "test mode", 5, 5, 0.5)454+455+ t.Run("produces valid puzzle", func(t *testing.T) {456+ hints := GenerateRandomTomography(mode)457+ if !isValidPuzzle(hints, mode.Height, mode.Width) {458+ t.Error("GenerateRandomTomography produced invalid puzzle")459+ }460+ })461+462+ t.Run("hints match dimensions", func(t *testing.T) {463+ hints := GenerateRandomTomography(mode)464+ if len(hints.rows) != mode.Height {465+ t.Errorf("len(rows) = %d, want %d", len(hints.rows), mode.Height)466+ }467+ if len(hints.cols) != mode.Width {468+ t.Errorf("len(cols) = %d, want %d", len(hints.cols), mode.Width)469+ }470+ })471+}472+473+// --- Save/Load round-trip (P1) ---474+475+func TestSaveLoadRoundTrip(t *testing.T) {476+ t.Run("round-trip preserves state", func(t *testing.T) {477+ g := grid{478+ []rune{filledTile, emptyTile, markedTile, filledTile, emptyTile},479+ []rune{emptyTile, filledTile, filledTile, emptyTile, filledTile},480+ []rune{filledTile, filledTile, emptyTile, emptyTile, filledTile},481+ []rune{emptyTile, emptyTile, filledTile, filledTile, emptyTile},482+ []rune{filledTile, emptyTile, filledTile, emptyTile, filledTile},483+ }484+ hints := Hints{485+ rows: TomographyDefinition{{2, 1}, {1, 2}, {2, 1}, {2}, {1, 1, 1}},486+ cols: TomographyDefinition{{1, 1, 1}, {1, 1}, {1, 1, 1}, {1, 1}, {1, 1, 1}},487+ }488+ curr := generateTomography(g)489+490+ m := Model{491+ width: 5,492+ height: 5,493+ rowHints: hints.rows,494+ colHints: hints.cols,495+ grid: g,496+ keys: DefaultKeyMap,497+ currentHints: curr,498+ solved: curr.rows.equal(hints.rows) && curr.cols.equal(hints.cols),499+ }500+501+ data, err := m.GetSave()502+ if err != nil {503+ t.Fatal(err)504+ }505+506+ got, err := ImportModel(data)507+ if err != nil {508+ t.Fatal(err)509+ }510+511+ // Grid content.512+ if got.grid.String() != m.grid.String() {513+ t.Errorf("grid mismatch:\n got: %q\n want: %q", got.grid.String(), m.grid.String())514+ }515+516+ // Dimensions.517+ if got.width != m.width {518+ t.Errorf("width = %d, want %d", got.width, m.width)519+ }520+ if got.height != m.height {521+ t.Errorf("height = %d, want %d", got.height, m.height)522+ }523+524+ // Hints.525+ if !got.rowHints.equal(m.rowHints) {526+ t.Errorf("rowHints mismatch: got %v, want %v", got.rowHints, m.rowHints)527+ }528+ if !got.colHints.equal(m.colHints) {529+ t.Errorf("colHints mismatch: got %v, want %v", got.colHints, m.colHints)530+ }531+532+ // Solved state.533+ if got.solved != m.solved {534+ t.Errorf("solved = %v, want %v", got.solved, m.solved)535+ }536+ })537+538+ t.Run("preserves solved state", func(t *testing.T) {539+ // Create a grid that matches its own tomography (solved).540+ g := grid{541+ []rune{filledTile, emptyTile},542+ []rune{emptyTile, filledTile},543+ }544+ hints := generateTomography(g)545+546+ m := Model{547+ width: 2,548+ height: 2,549+ rowHints: hints.rows,550+ colHints: hints.cols,551+ grid: g,552+ keys: DefaultKeyMap,553+ currentHints: hints,554+ solved: true,555+ }556+557+ data, err := m.GetSave()558+ if err != nil {559+ t.Fatal(err)560+ }561+562+ got, err := ImportModel(data)563+ if err != nil {564+ t.Fatal(err)565+ }566+567+ if !got.solved {568+ t.Error("expected imported model to be solved")569+ }570+ })571+572+ t.Run("invalid JSON", func(t *testing.T) {573+ _, err := ImportModel([]byte("not json"))574+ if err == nil {575+ t.Fatal("expected error for invalid JSON")576+ }577+ })578+}579+580+// Ensure the game import is used (it's needed for the init() registration side effect581+// and the NewMode constructor uses game.NewBaseMode internally).582+var _ game.Gamer = Model{}
A sudoku/sudoku_test.go +657 -0
@@ -0,0 +1,657 @@
1+package sudoku2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+)8+9+// validCompleteGrid returns a known valid 9x9 Sudoku solution.10+func validCompleteGrid() grid {11+ rows := [9][9]int{12+ {5, 3, 4, 6, 7, 8, 9, 1, 2},13+ {6, 7, 2, 1, 9, 5, 3, 4, 8},14+ {1, 9, 8, 3, 4, 2, 5, 6, 7},15+ {8, 5, 9, 7, 6, 1, 4, 2, 3},16+ {4, 2, 6, 8, 5, 3, 7, 9, 1},17+ {7, 1, 3, 9, 2, 4, 8, 5, 6},18+ {9, 6, 1, 5, 3, 7, 2, 8, 4},19+ {2, 8, 7, 4, 1, 9, 6, 3, 5},20+ {3, 4, 5, 2, 8, 6, 1, 7, 9},21+ }22+ var g grid23+ for y := range gridSize {24+ for x := range gridSize {25+ g[y][x] = cell{x: x, y: y, v: rows[y][x]}26+ }27+ }28+ return g29+}30+31+// emptyGrid returns a 9x9 grid with all cells set to 0 and correct x/y.32+func emptyGrid() grid {33+ return newGrid(nil)34+}35+36+// --- isValid (P0) ---37+38+func TestIsValid(t *testing.T) {39+ t.Run("valid placement on empty board", func(t *testing.T) {40+ g := emptyGrid()41+ if !isValid(&g, 5, 0, 0) {42+ t.Error("expected valid placement on empty grid")43+ }44+ })45+46+ t.Run("row conflict", func(t *testing.T) {47+ g := emptyGrid()48+ g[0][3].v = 5 // place 5 in row 0, col 349+ if isValid(&g, 5, 0, 0) {50+ t.Error("expected row conflict")51+ }52+ })53+54+ t.Run("column conflict", func(t *testing.T) {55+ g := emptyGrid()56+ g[3][0].v = 5 // place 5 in col 0, row 357+ if isValid(&g, 5, 0, 0) {58+ t.Error("expected column conflict")59+ }60+ })61+62+ t.Run("box conflict", func(t *testing.T) {63+ g := emptyGrid()64+ g[1][1].v = 5 // same box as (0,0)65+ if isValid(&g, 5, 0, 0) {66+ t.Error("expected box conflict")67+ }68+ })69+70+ t.Run("no self-conflict", func(t *testing.T) {71+ g := emptyGrid()72+ g[2][2].v = 573+ // Checking isValid for 5 at (2,2) should skip self.74+ if !isValid(&g, 5, 2, 2) {75+ t.Error("expected no self-conflict")76+ }77+ })78+79+ t.Run("different value same position", func(t *testing.T) {80+ g := emptyGrid()81+ g[0][0].v = 382+ if !isValid(&g, 5, 0, 0) {83+ t.Error("expected no conflict for different value")84+ }85+ })86+87+ t.Run("box boundary (2,2) vs (3,3) different boxes", func(t *testing.T) {88+ g := emptyGrid()89+ g[3][3].v = 5 // box (1,1), different from (0,0) box90+ if !isValid(&g, 5, 2, 2) {91+ t.Error("(2,2) and (3,3) are in different boxes, no conflict expected")92+ }93+ })94+95+ t.Run("box boundary (2,2) vs (0,0) same box", func(t *testing.T) {96+ g := emptyGrid()97+ g[0][0].v = 5 // same box as (2,2)98+ if isValid(&g, 5, 2, 2) {99+ t.Error("(0,0) and (2,2) are in the same box, conflict expected")100+ }101+ })102+103+ t.Run("all 9 boxes center placement", func(t *testing.T) {104+ // Place value 1 in center of each 3x3 box, then verify no cross-box conflict.105+ g := emptyGrid()106+ centers := [][2]int{107+ {1, 1}, {4, 1}, {7, 1},108+ {1, 4}, {4, 4}, {7, 4},109+ {1, 7}, {4, 7}, {7, 7},110+ }111+ for _, c := range centers {112+ g[c[1]][c[0]].v = 1113+ }114+ // Each center should be valid (1 appears once per box, once per row, once per column).115+ for _, c := range centers {116+ if !isValid(&g, 1, c[0], c[1]) {117+ t.Errorf("expected valid at center (%d,%d)", c[0], c[1])118+ }119+ }120+ })121+}122+123+// --- fillGrid (P1) ---124+125+func TestFillGrid(t *testing.T) {126+ t.Run("fills empty grid", func(t *testing.T) {127+ g := emptyGrid()128+ if !fillGrid(&g) {129+ t.Fatal("fillGrid returned false")130+ }131+ for y := range gridSize {132+ for x := range gridSize {133+ if g[y][x].v < 1 || g[y][x].v > 9 {134+ t.Errorf("cell (%d,%d) = %d, want 1-9", x, y, g[y][x].v)135+ }136+ }137+ }138+ })139+140+ t.Run("result is valid", func(t *testing.T) {141+ g := emptyGrid()142+ fillGrid(&g)143+ for y := range gridSize {144+ for x := range gridSize {145+ if !isValid(&g, g[y][x].v, x, y) {146+ t.Errorf("cell (%d,%d)=%d fails isValid", x, y, g[y][x].v)147+ }148+ }149+ }150+ })151+152+ t.Run("fills partial grid", func(t *testing.T) {153+ provided := []cell{154+ {x: 0, y: 0, v: 5},155+ {x: 1, y: 0, v: 3},156+ }157+ g := newGrid(provided)158+ if !fillGrid(&g) {159+ t.Fatal("fillGrid returned false on partial grid")160+ }161+ if g[0][0].v != 5 {162+ t.Errorf("provided cell (0,0) changed to %d", g[0][0].v)163+ }164+ if g[0][1].v != 3 {165+ t.Errorf("provided cell (1,0) changed to %d", g[0][1].v)166+ }167+ })168+169+ t.Run("produces different results", func(t *testing.T) {170+ var grids [5]grid171+ for i := range grids {172+ grids[i] = emptyGrid()173+ fillGrid(&grids[i])174+ }175+ allSame := true176+ for i := 1; i < len(grids); i++ {177+ for y := range gridSize {178+ for x := range gridSize {179+ if grids[i][y][x].v != grids[0][y][x].v {180+ allSame = false181+ break182+ }183+ }184+ if !allSame {185+ break186+ }187+ }188+ if !allSame {189+ break190+ }191+ }192+ if allSame {193+ t.Error("fillGrid produced identical grids across 5 runs")194+ }195+ })196+}197+198+// --- countSolutions (P0) ---199+200+func TestCountSolutions(t *testing.T) {201+ t.Run("complete grid", func(t *testing.T) {202+ g := validCompleteGrid()203+ if count := countSolutions(&g, 10); count != 1 {204+ t.Errorf("countSolutions = %d, want 1", count)205+ }206+ })207+208+ t.Run("one empty cell unique", func(t *testing.T) {209+ g := validCompleteGrid()210+ g[0][0].v = 0211+ if count := countSolutions(&g, 10); count != 1 {212+ t.Errorf("countSolutions = %d, want 1", count)213+ }214+ })215+216+ t.Run("impossible grid", func(t *testing.T) {217+ g := emptyGrid()218+ // Place two 1s in row 0 by forcing the grid state.219+ g[0][0].v = 1220+ g[0][1].v = 1221+ if count := countSolutions(&g, 10); count != 0 {222+ t.Errorf("countSolutions = %d, want 0", count)223+ }224+ })225+226+ t.Run("respects limit", func(t *testing.T) {227+ g := emptyGrid()228+ count := countSolutions(&g, 2)229+ if count < 2 {230+ t.Errorf("countSolutions = %d, want >= 2", count)231+ }232+ if count > 2 {233+ t.Errorf("countSolutions = %d, should not exceed limit 2", count)234+ }235+ })236+237+ t.Run("two empty cells ambiguous", func(t *testing.T) {238+ // Start with a valid complete grid, then clear two cells that create239+ // ambiguity: (0,0) and (0,1) where swapping their values still yields240+ // a valid grid.241+ g := validCompleteGrid()242+ // g[0][0]=5, g[0][1]=3. We need to check if swapping creates a valid grid.243+ // Clear both and count solutions.244+ v1, v2 := g[0][0].v, g[0][1].v245+ g[0][0].v = 0246+ g[0][1].v = 0247+ count := countSolutions(&g, 10)248+ // If swapping is valid, count >= 2; otherwise find a different pair.249+ // The valid grid has (0,0)=5,(0,1)=3 — swapping: is 3 valid at (0,0)250+ // and 5 valid at (0,1)?251+ g[0][0].v = v2252+ g[0][1].v = v1253+ swapValid := isValid(&g, v2, 0, 0) && isValid(&g, v1, 1, 0)254+ if swapValid && count < 2 {255+ t.Errorf("countSolutions = %d, expected >= 2 for ambiguous pair", count)256+ }257+ if !swapValid && count != 1 {258+ // The pair wasn't actually ambiguous; just verify we got 1.259+ t.Logf("pair (0,0)/(0,1) not ambiguous, count=%d", count)260+ }261+ })262+}263+264+// --- computeConflicts (P0) ---265+266+func TestComputeConflicts(t *testing.T) {267+ t.Run("no conflicts", func(t *testing.T) {268+ g := validCompleteGrid()269+ conflicts := computeConflicts(g)270+ for y := range gridSize {271+ for x := range gridSize {272+ if conflicts[y][x] {273+ t.Errorf("unexpected conflict at (%d,%d)", x, y)274+ }275+ }276+ }277+ })278+279+ t.Run("row conflict", func(t *testing.T) {280+ g := validCompleteGrid()281+ // Create a row conflict: set (0,1) to same value as (0,0).282+ g[0][1].v = g[0][0].v283+ conflicts := computeConflicts(g)284+ if !conflicts[0][0] {285+ t.Error("expected conflict at (0,0)")286+ }287+ if !conflicts[0][1] {288+ t.Error("expected conflict at (1,0)")289+ }290+ })291+292+ t.Run("column conflict", func(t *testing.T) {293+ g := validCompleteGrid()294+ // Create a column conflict: set (0,1) row to same as (0,0).295+ g[1][0].v = g[0][0].v296+ conflicts := computeConflicts(g)297+ if !conflicts[0][0] {298+ t.Error("expected conflict at (0,0)")299+ }300+ if !conflicts[1][0] {301+ t.Error("expected conflict at (0,1)")302+ }303+ })304+305+ t.Run("box conflict", func(t *testing.T) {306+ g := validCompleteGrid()307+ // Create a box conflict: set (1,1) to same value as (0,0).308+ g[1][1].v = g[0][0].v309+ conflicts := computeConflicts(g)310+ if !conflicts[0][0] {311+ t.Error("expected conflict at (0,0)")312+ }313+ if !conflicts[1][1] {314+ t.Error("expected conflict at (1,1)")315+ }316+ })317+318+ t.Run("empty cells no false positives", func(t *testing.T) {319+ g := emptyGrid()320+ // Place a single value.321+ g[0][0].v = 5322+ conflicts := computeConflicts(g)323+ for y := range gridSize {324+ for x := range gridSize {325+ if conflicts[y][x] {326+ t.Errorf("unexpected conflict at (%d,%d) on mostly empty grid", x, y)327+ }328+ }329+ }330+ })331+332+ t.Run("multiple conflicts", func(t *testing.T) {333+ g := validCompleteGrid()334+ // Row conflict in row 0.335+ g[0][1].v = g[0][0].v336+ // Column conflict in column 5.337+ g[3][5].v = g[0][5].v338+ conflicts := computeConflicts(g)339+ if !conflicts[0][0] || !conflicts[0][1] {340+ t.Error("expected row conflict at (0,0) and (1,0)")341+ }342+ if !conflicts[0][5] || !conflicts[3][5] {343+ t.Error("expected column conflict at (5,0) and (5,3)")344+ }345+ })346+}347+348+// --- isSolvedWith (P0) ---349+350+func TestIsSolvedWith(t *testing.T) {351+ t.Run("valid complete grid", func(t *testing.T) {352+ g := validCompleteGrid()353+ conflicts := computeConflicts(g)354+ if !isSolvedWith(g, conflicts) {355+ t.Error("expected solved")356+ }357+ })358+359+ t.Run("has empty cell", func(t *testing.T) {360+ g := validCompleteGrid()361+ g[4][4].v = 0362+ conflicts := computeConflicts(g)363+ if isSolvedWith(g, conflicts) {364+ t.Error("expected not solved with empty cell")365+ }366+ })367+368+ t.Run("has conflict", func(t *testing.T) {369+ g := validCompleteGrid()370+ g[0][1].v = g[0][0].v // duplicate in row371+ conflicts := computeConflicts(g)372+ if isSolvedWith(g, conflicts) {373+ t.Error("expected not solved with conflict")374+ }375+ })376+377+ t.Run("all zeros", func(t *testing.T) {378+ g := emptyGrid()379+ conflicts := computeConflicts(g)380+ if isSolvedWith(g, conflicts) {381+ t.Error("expected not solved for blank grid")382+ }383+ })384+}385+386+// --- newGrid (P1) ---387+388+func TestNewGrid(t *testing.T) {389+ t.Run("with provided cells", func(t *testing.T) {390+ provided := []cell{391+ {x: 0, y: 0, v: 5},392+ {x: 4, y: 3, v: 9},393+ {x: 8, y: 8, v: 1},394+ }395+ g := newGrid(provided)396+ for _, c := range provided {397+ if g[c.y][c.x].v != c.v {398+ t.Errorf("cell (%d,%d) = %d, want %d", c.x, c.y, g[c.y][c.x].v, c.v)399+ }400+ }401+ })402+403+ t.Run("without provided cells", func(t *testing.T) {404+ g := newGrid(nil)405+ for y := range gridSize {406+ for x := range gridSize {407+ if g[y][x].v != 0 {408+ t.Errorf("cell (%d,%d) = %d, want 0", x, y, g[y][x].v)409+ }410+ }411+ }412+ })413+414+ t.Run("all cells have correct positions", func(t *testing.T) {415+ g := newGrid(nil)416+ for y := range gridSize {417+ for x := range gridSize {418+ if g[y][x].x != x || g[y][x].y != y {419+ t.Errorf("cell position = (%d,%d), want (%d,%d)", g[y][x].x, g[y][x].y, x, y)420+ }421+ }422+ }423+ })424+}425+426+// --- gridToString (P1) ---427+428+func TestGridToString(t *testing.T) {429+ t.Run("round-trip", func(t *testing.T) {430+ g := validCompleteGrid()431+ s := gridToString(g)432+433+ // Parse it back.434+ g2 := emptyGrid()435+ rows := splitLines(s)436+ for y := range gridSize {437+ for x := range gridSize {438+ g2[y][x].v = int(rows[y][x] - '0')439+ }440+ }441+ for y := range gridSize {442+ for x := range gridSize {443+ if g2[y][x].v != g[y][x].v {444+ t.Errorf("round-trip mismatch at (%d,%d): got %d, want %d", x, y, g2[y][x].v, g[y][x].v)445+ }446+ }447+ }448+ })449+450+ t.Run("encodes zeros", func(t *testing.T) {451+ g := emptyGrid()452+ s := gridToString(g)453+ for _, ch := range s {454+ if ch != '0' && ch != '\n' {455+ t.Errorf("expected only '0' and '\\n', got %q", ch)456+ }457+ }458+ })459+460+ t.Run("encodes 1-9", func(t *testing.T) {461+ g := validCompleteGrid()462+ s := gridToString(g)463+ for _, ch := range s {464+ if ch == '\n' {465+ continue466+ }467+ if ch < '1' || ch > '9' {468+ t.Errorf("expected digit 1-9, got %q", ch)469+ }470+ }471+ })472+}473+474+// splitLines splits a string by newlines (helper for test).475+func splitLines(s string) []string {476+ var lines []string477+ start := 0478+ for i := range len(s) {479+ if s[i] == '\n' {480+ lines = append(lines, s[start:i])481+ start = i + 1482+ }483+ }484+ lines = append(lines, s[start:])485+ return lines486+}487+488+// --- GenerateProvidedCells (P2) ---489+490+func TestGenerateProvidedCells(t *testing.T) {491+ mode := NewMode("Test", "test", 30)492+493+ t.Run("produces solvable puzzle", func(t *testing.T) {494+ cells := GenerateProvidedCells(mode)495+ g := newGrid(cells)496+ count := countSolutions(&g, 2)497+ if count != 1 {498+ t.Errorf("countSolutions = %d, want 1", count)499+ }500+ })501+502+ t.Run("approximate count", func(t *testing.T) {503+ cells := GenerateProvidedCells(mode)504+ // May be slightly higher than target if removal would break uniqueness.505+ if len(cells) < mode.ProvidedCount {506+ t.Errorf("len(cells) = %d, want >= %d", len(cells), mode.ProvidedCount)507+ }508+ })509+510+ t.Run("all provided cells valid", func(t *testing.T) {511+ cells := GenerateProvidedCells(mode)512+ g := newGrid(cells)513+ for _, c := range cells {514+ if !isValid(&g, c.v, c.x, c.y) {515+ t.Errorf("provided cell (%d,%d)=%d fails isValid", c.x, c.y, c.v)516+ }517+ }518+ })519+520+ t.Run("cells have correct positions", func(t *testing.T) {521+ cells := GenerateProvidedCells(mode)522+ for _, c := range cells {523+ if c.x < 0 || c.x > 8 || c.y < 0 || c.y > 8 {524+ t.Errorf("cell position (%d,%d) out of bounds", c.x, c.y)525+ }526+ if c.v < 1 || c.v > 9 {527+ t.Errorf("cell value %d out of range at (%d,%d)", c.v, c.x, c.y)528+ }529+ }530+ })531+}532+533+// --- Save/Load round-trip (P1) ---534+535+func TestSaveLoadRoundTrip(t *testing.T) {536+ t.Run("preserves provided and user cells", func(t *testing.T) {537+ provided := []cell{538+ {x: 0, y: 0, v: 5},539+ {x: 1, y: 0, v: 3},540+ {x: 4, y: 4, v: 9},541+ }542+ g := newGrid(provided)543+ // Add user-entered values.544+ g[1][0].v = 6545+ g[2][2].v = 8546+547+ m := Model{548+ grid: g,549+ provided: provided,550+ providedGrid: buildProvidedGrid(provided),551+ keys: DefaultKeyMap,552+ cursor: game.Cursor{X: 2, Y: 3},553+ modeTitle: "Hard",554+ }555+556+ data, err := m.GetSave()557+ if err != nil {558+ t.Fatal(err)559+ }560+561+ got, err := ImportModel(data)562+ if err != nil {563+ t.Fatal(err)564+ }565+566+ // Provided cells preserved.567+ for _, c := range provided {568+ if got.grid[c.y][c.x].v != c.v {569+ t.Errorf("provided cell (%d,%d) = %d, want %d", c.x, c.y, got.grid[c.y][c.x].v, c.v)570+ }571+ if !got.providedGrid[c.y][c.x] {572+ t.Errorf("providedGrid[%d][%d] = false, want true", c.y, c.x)573+ }574+ }575+576+ // User-entered values preserved.577+ if got.grid[1][0].v != 6 {578+ t.Errorf("user cell (0,1) = %d, want 6", got.grid[1][0].v)579+ }580+ if got.grid[2][2].v != 8 {581+ t.Errorf("user cell (2,2) = %d, want 8", got.grid[2][2].v)582+ }583+584+ // Non-provided cells should not be in providedGrid.585+ if got.providedGrid[1][0] {586+ t.Error("user cell (0,1) should not be marked as provided")587+ }588+ })589+590+ t.Run("provided vs user distinction", func(t *testing.T) {591+ provided := []cell{592+ {x: 0, y: 0, v: 5},593+ }594+ m := Model{595+ grid: newGrid(provided),596+ provided: provided,597+ providedGrid: buildProvidedGrid(provided),598+ keys: DefaultKeyMap,599+ }600+601+ data, err := m.GetSave()602+ if err != nil {603+ t.Fatal(err)604+ }605+606+ got, err := ImportModel(data)607+ if err != nil {608+ t.Fatal(err)609+ }610+611+ if !got.providedGrid[0][0] {612+ t.Error("expected (0,0) to be marked as provided")613+ }614+ if got.providedGrid[0][1] {615+ t.Error("expected (1,0) to not be marked as provided")616+ }617+ })618+619+ t.Run("empty user input", func(t *testing.T) {620+ provided := []cell{621+ {x: 0, y: 0, v: 5},622+ {x: 8, y: 8, v: 1},623+ }624+ m := Model{625+ grid: newGrid(provided),626+ provided: provided,627+ providedGrid: buildProvidedGrid(provided),628+ keys: DefaultKeyMap,629+ }630+631+ data, err := m.GetSave()632+ if err != nil {633+ t.Fatal(err)634+ }635+636+ got, err := ImportModel(data)637+ if err != nil {638+ t.Fatal(err)639+ }640+641+ // All non-provided cells should be 0.642+ for y := range gridSize {643+ for x := range gridSize {644+ if !got.providedGrid[y][x] && got.grid[y][x].v != 0 {645+ t.Errorf("non-provided cell (%d,%d) = %d, want 0", x, y, got.grid[y][x].v)646+ }647+ }648+ }649+ })650+651+ t.Run("invalid JSON", func(t *testing.T) {652+ _, err := ImportModel([]byte("not json"))653+ if err == nil {654+ t.Fatal("expected error for invalid JSON")655+ }656+ })657+}
M takuzu/style.go +1 -1
A takuzu/takuzu_test.go +918 -0
@@ -0,0 +1,918 @@
1+package takuzu2+3+import (4+ "encoding/json"5+ "testing"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// --- Helpers ---11+12+func makeGrid6(rows ...string) grid {13+ if len(rows) != 6 {14+ panic("makeGrid6 requires exactly 6 rows")15+ }16+ g := make(grid, 6)17+ for i, row := range rows {18+ g[i] = []rune(row)19+ }20+ return g21+}22+23+func validGrid6() grid {24+ // A valid 6x6 Takuzu grid (3 zeros, 3 ones per line, no triples, unique lines)25+ return makeGrid6(26+ "001011",27+ "110100",28+ "010101",29+ "101010",30+ "100110",31+ "011001",32+ )33+}34+35+// --- canPlace (P0) ---36+37+func TestCanPlace_Horizontal_Triples(t *testing.T) {38+ tests := []struct {39+ name string40+ row string41+ x int42+ val rune43+ want bool44+ }{45+ {"00. placing 0 at x=2", "00....", 2, zeroCell, false},46+ {"00. placing 1 at x=2", "00....", 2, oneCell, true},47+ {".00 placing 0 at x=0", ".00...", 0, zeroCell, false},48+ {".00 placing 1 at x=0", ".00...", 0, oneCell, true},49+ {"0.0 placing 0 at x=1", "0.0...", 1, zeroCell, false},50+ {"0.0 placing 1 at x=1", "0.0...", 1, oneCell, true},51+ {"01. placing 0 at x=2", "01....", 2, zeroCell, true},52+ {"11. placing 1 at x=2", "11....", 2, oneCell, false},53+ {".11 placing 1 at x=0", ".11...", 0, oneCell, false},54+ {"1.1 placing 1 at x=1", "1.1...", 1, oneCell, false},55+ }56+57+ for _, tt := range tests {58+ t.Run(tt.name, func(t *testing.T) {59+ g := makeGrid6(tt.row, "......", "......", "......", "......", "......")60+ if got := canPlace(g, 6, tt.x, 0, tt.val); got != tt.want {61+ t.Errorf("canPlace = %v, want %v", got, tt.want)62+ }63+ })64+ }65+}66+67+func TestCanPlace_Vertical_Triples(t *testing.T) {68+ tests := []struct {69+ name string70+ col string // column 0 values by row index; '.' = leave empty71+ y int72+ val rune73+ want bool74+ }{75+ {"y-2,y-1 match: 00. placing 0 at y=2", "00....", 2, zeroCell, false},76+ {"y-2,y-1 match: 00. placing 1 at y=2", "00....", 2, oneCell, true},77+ {"y+1,y+2 match: .00 placing 0 at y=0", ".00...", 0, zeroCell, false},78+ {"y-1,y+1 match: 0.0 placing 0 at y=1", "0.0...", 1, zeroCell, false},79+ {"y-1,y+1 match: 0.0 placing 1 at y=1", "0.0...", 1, oneCell, true},80+ {"no triple: 01. placing 0 at y=2", "01....", 2, zeroCell, true},81+ {"y-2,y-1 match ones: 11. placing 1 at y=2", "11....", 2, oneCell, false},82+ {"y+1,y+2 match ones: .11 placing 1 at y=0", ".11...", 0, oneCell, false},83+ {"y-1,y+1 match ones: 1.1 placing 1 at y=1", "1.1...", 1, oneCell, false},84+ {"bottom edge: ....00 placing 0 at y=3", "....00", 3, zeroCell, false},85+ }86+87+ for _, tt := range tests {88+ t.Run(tt.name, func(t *testing.T) {89+ g := make(grid, 6)90+ for y := range 6 {91+ g[y] = []rune("......")92+ if y < len(tt.col) && tt.col[y] != '.' {93+ g[y][0] = rune(tt.col[y])94+ }95+ }96+ if got := canPlace(g, 6, 0, tt.y, tt.val); got != tt.want {97+ t.Errorf("canPlace = %v, want %v", got, tt.want)98+ }99+ })100+ }101+}102+103+func TestCanPlace_EqualCounts(t *testing.T) {104+ t.Run("row at limit for zeros", func(t *testing.T) {105+ g := makeGrid6("000...", "......", "......", "......", "......", "......")106+ // Row already has 3 zeros, can't place another.107+ if got := canPlace(g, 6, 3, 0, zeroCell); got != false {108+ t.Error("canPlace should reject when row has size/2 zeros")109+ }110+ })111+112+ t.Run("row at limit for ones", func(t *testing.T) {113+ g := makeGrid6("111...", "......", "......", "......", "......", "......")114+ if got := canPlace(g, 6, 3, 0, oneCell); got != false {115+ t.Error("canPlace should reject when row has size/2 ones")116+ }117+ })118+119+ t.Run("row has room for both", func(t *testing.T) {120+ g := makeGrid6("01....", "......", "......", "......", "......", "......")121+ // Only 1 zero and 1 one, room for both.122+ if got := canPlace(g, 6, 2, 0, zeroCell); got != true {123+ t.Error("canPlace should allow when row has room")124+ }125+ if got := canPlace(g, 6, 2, 0, oneCell); got != true {126+ t.Error("canPlace should allow when row has room")127+ }128+ })129+130+ t.Run("column at limit", func(t *testing.T) {131+ g := make(grid, 6)132+ for y := range 6 {133+ g[y] = []rune("......")134+ }135+ // Put 3 zeros in column 0.136+ g[0][0] = zeroCell137+ g[1][0] = zeroCell138+ g[2][0] = zeroCell139+140+ if got := canPlace(g, 6, 0, 3, zeroCell); got != false {141+ t.Error("canPlace should reject when column has size/2 zeros")142+ }143+ })144+}145+146+func TestCanPlace_UniqueLines(t *testing.T) {147+ t.Run("completing row creates duplicate", func(t *testing.T) {148+ g := makeGrid6(149+ "00101.", // Will complete to 001011150+ "001011", // Already exists151+ "......",152+ "......",153+ "......",154+ "......",155+ )156+ // Placing '1' at (5, 0) would make row 0 identical to row 1.157+ if got := canPlace(g, 6, 5, 0, oneCell); got != false {158+ t.Error("canPlace should reject when completing row creates duplicate")159+ }160+ })161+162+ t.Run("completing row is unique", func(t *testing.T) {163+ g := makeGrid6(164+ "01101.", // Will complete to 011010 (3 zeros, 3 ones)165+ "001011", // Different166+ "......",167+ "......",168+ "......",169+ "......",170+ )171+ // Placing '0' at (5, 0) makes row 0 unique and has valid counts.172+ if got := canPlace(g, 6, 5, 0, zeroCell); got != true {173+ t.Error("canPlace should allow unique row completion")174+ }175+ })176+177+ t.Run("completing column creates duplicate", func(t *testing.T) {178+ g := make(grid, 6)179+ for y := range 6 {180+ g[y] = []rune("......")181+ }182+ // Column 0: 00101. Column 1: 001011 (complete)183+ g[0][0], g[1][0], g[2][0], g[3][0], g[4][0] = '0', '0', '1', '0', '1'184+ g[0][1], g[1][1], g[2][1], g[3][1], g[4][1], g[5][1] = '0', '0', '1', '0', '1', '1'185+186+ // Placing '1' at (0, 5) would duplicate column 1.187+ if got := canPlace(g, 6, 0, 5, oneCell); got != false {188+ t.Error("canPlace should reject when completing column creates duplicate")189+ }190+ })191+192+ t.Run("row not yet complete no uniqueness check", func(t *testing.T) {193+ g := makeGrid6(194+ "0010..", // Not complete195+ "001011",196+ "......",197+ "......",198+ "......",199+ "......",200+ )201+ // Placing at (4, 0) doesn't trigger uniqueness check (row still incomplete after).202+ if got := canPlace(g, 6, 4, 0, oneCell); got != true {203+ t.Error("canPlace should allow when row not fully complete")204+ }205+ })206+}207+208+// --- rowEqual / colEqual (P0) ---209+210+func TestRowEqual(t *testing.T) {211+ tests := []struct {212+ name string213+ a, b []rune214+ want bool215+ }{216+ {"identical", []rune{'0', '1', '0', '1'}, []rune{'0', '1', '0', '1'}, true},217+ {"different", []rune{'0', '1', '0', '1'}, []rune{'0', '1', '1', '0'}, false},218+ {"different lengths", []rune{'0', '1'}, []rune{'0', '1', '0'}, false},219+ {"both empty", []rune{}, []rune{}, true},220+ }221+222+ for _, tt := range tests {223+ t.Run(tt.name, func(t *testing.T) {224+ if got := rowEqual(tt.a, tt.b); got != tt.want {225+ t.Errorf("rowEqual = %v, want %v", got, tt.want)226+ }227+ })228+ }229+}230+231+func TestColEqual(t *testing.T) {232+ g := makeGrid6(233+ "001011",234+ "010101",235+ "101010",236+ "110100",237+ "001011",238+ "010001",239+ )240+241+ t.Run("identical columns", func(t *testing.T) {242+ // Columns 2 and 4 are both [1,0,1,0,1,0].243+ if !colEqual(g, 6, 2, 4) {244+ t.Error("colEqual should return true for identical columns")245+ }246+ })247+248+ t.Run("different columns", func(t *testing.T) {249+ if colEqual(g, 6, 0, 1) {250+ t.Error("colEqual should return false for different columns")251+ }252+ })253+}254+255+// --- rowEqualWith / colEqualWith (P0) ---256+257+func TestRowEqualWith(t *testing.T) {258+ a := []rune{'0', '0', '1', '0', '1', '1'}259+ b := []rune{'0', '0', '1', '0', '1', '1'}260+261+ t.Run("override makes no difference when already equal", func(t *testing.T) {262+ // a[3]='0', override with '0', should still match b[3]='0'263+ if !rowEqualWith(a, b, 3, '0') {264+ t.Error("rowEqualWith should return true")265+ }266+ })267+268+ t.Run("override fixes difference", func(t *testing.T) {269+ b2 := []rune{'0', '0', '1', '1', '1', '1'} // Differs at index 3270+ if !rowEqualWith(a, b2, 3, '1') {271+ t.Error("rowEqualWith should return true when override fixes it")272+ }273+ })274+275+ t.Run("override doesn't help", func(t *testing.T) {276+ b2 := []rune{'1', '0', '1', '0', '1', '1'} // Differs at index 0277+ if rowEqualWith(a, b2, 3, '1') {278+ t.Error("rowEqualWith should return false when override doesn't match")279+ }280+ })281+282+ t.Run("different lengths", func(t *testing.T) {283+ b2 := []rune{'0', '0', '1'}284+ if rowEqualWith(a, b2, 0, '0') {285+ t.Error("rowEqualWith should return false for different lengths")286+ }287+ })288+}289+290+func TestColEqualWith(t *testing.T) {291+ g := makeGrid6(292+ "001011",293+ "001011",294+ "101010",295+ "110100",296+ "001011",297+ "001001",298+ )299+300+ t.Run("override fixes difference", func(t *testing.T) {301+ // Column 0 and 1 differ only at row 2 (g[2][0]='1', g[2][1]='0')302+ // If we treat g[2][0] as '0', they'd be equal.303+ if !colEqualWith(g, 6, 0, 1, 2, '0') {304+ t.Error("colEqualWith should return true when override matches")305+ }306+ })307+308+ t.Run("override doesn't help", func(t *testing.T) {309+ if colEqualWith(g, 6, 0, 2, 1, '0') {310+ t.Error("colEqualWith should return false when columns differ elsewhere")311+ }312+ })313+}314+315+// --- rowFilled / colFilled / rowFilledExcept / colFilledExcept (P1) ---316+317+func TestRowFilled(t *testing.T) {318+ g := makeGrid6(319+ "001011",320+ "01....",321+ "......",322+ "101010",323+ "......",324+ "......",325+ )326+327+ tests := []struct {328+ name string329+ y int330+ want bool331+ }{332+ {"all filled", 0, true},333+ {"has empty", 1, false},334+ {"all empty", 2, false},335+ {"all filled again", 3, true},336+ }337+338+ for _, tt := range tests {339+ t.Run(tt.name, func(t *testing.T) {340+ if got := rowFilled(g, tt.y, 6); got != tt.want {341+ t.Errorf("rowFilled(row %d) = %v, want %v", tt.y, got, tt.want)342+ }343+ })344+ }345+}346+347+func TestColFilled(t *testing.T) {348+ g := makeGrid6(349+ "0.....",350+ "0.....",351+ "1.....",352+ "0.....",353+ "1.....",354+ "1.....",355+ )356+357+ t.Run("all filled", func(t *testing.T) {358+ if !colFilled(g, 0, 6) {359+ t.Error("colFilled should return true for filled column")360+ }361+ })362+363+ t.Run("has empty", func(t *testing.T) {364+ if colFilled(g, 1, 6) {365+ t.Error("colFilled should return false for empty column")366+ }367+ })368+}369+370+func TestRowFilledExcept(t *testing.T) {371+ g := makeGrid6(372+ "00101.",373+ "0.1...",374+ "......",375+ "......",376+ "......",377+ "......",378+ )379+380+ t.Run("skip is only empty", func(t *testing.T) {381+ if !rowFilledExcept(g, 0, 5, 6) {382+ t.Error("rowFilledExcept should return true when skip is only empty cell")383+ }384+ })385+386+ t.Run("another empty exists", func(t *testing.T) {387+ if rowFilledExcept(g, 1, 1, 6) {388+ t.Error("rowFilledExcept should return false when other empties exist")389+ }390+ })391+392+ t.Run("all empty except skip", func(t *testing.T) {393+ if rowFilledExcept(g, 2, 0, 6) {394+ t.Error("rowFilledExcept should return false for all-empty row")395+ }396+ })397+}398+399+func TestColFilledExcept(t *testing.T) {400+ g := makeGrid6(401+ "0.....",402+ "0.....",403+ "......",404+ "1.....",405+ "0.....",406+ "1.....",407+ )408+409+ t.Run("skip is only empty", func(t *testing.T) {410+ if !colFilledExcept(g, 0, 2, 6) {411+ t.Error("colFilledExcept should return true when skip is only empty")412+ }413+ })414+415+ t.Run("another empty exists", func(t *testing.T) {416+ if colFilledExcept(g, 1, 0, 6) {417+ t.Error("colFilledExcept should return false when column has other empties")418+ }419+ })420+}421+422+// --- checkConstraints (P0) ---423+424+func TestCheckConstraints(t *testing.T) {425+ t.Run("valid grid", func(t *testing.T) {426+ g := validGrid6()427+ if !checkConstraints(g, 6) {428+ t.Error("checkConstraints should return true for valid grid")429+ }430+ })431+432+ t.Run("triple in row", func(t *testing.T) {433+ g := makeGrid6(434+ "000111", // Triple 0s and triple 1s435+ "010101",436+ "101010",437+ "110100",438+ "100110",439+ "011001",440+ )441+ if checkConstraints(g, 6) {442+ t.Error("checkConstraints should reject grid with triple in row")443+ }444+ })445+446+ t.Run("triple in column", func(t *testing.T) {447+ g := makeGrid6(448+ "001011",449+ "001011",450+ "001011", // Column 0 has triple 0s451+ "110100",452+ "100110",453+ "011001",454+ )455+ if checkConstraints(g, 6) {456+ t.Error("checkConstraints should reject grid with triple in column")457+ }458+ })459+460+ t.Run("unequal row count", func(t *testing.T) {461+ g := makeGrid6(462+ "000011", // Valid count463+ "010101",464+ "101010",465+ "111000", // Valid count466+ "100110",467+ "011001",468+ )469+ // Modify to break count: row 0 now has 4 zeros, 2 ones.470+ g[0] = []rune("000001")471+ if checkConstraints(g, 6) {472+ t.Error("checkConstraints should reject unequal row count")473+ }474+ })475+476+ t.Run("unequal column count", func(t *testing.T) {477+ g := validGrid6()478+ // Break column 0 count.479+ g[0][0] = oneCell480+ g[1][0] = oneCell481+ g[2][0] = oneCell482+ g[3][0] = oneCell // Now column has 4 ones483+ if checkConstraints(g, 6) {484+ t.Error("checkConstraints should reject unequal column count")485+ }486+ })487+}488+489+// --- hasUniqueLines (P0) ---490+491+func TestHasUniqueLines(t *testing.T) {492+ t.Run("all unique", func(t *testing.T) {493+ g := validGrid6()494+ if !hasUniqueLines(g, 6) {495+ t.Error("hasUniqueLines should return true for unique grid")496+ }497+ })498+499+ t.Run("duplicate rows", func(t *testing.T) {500+ g := makeGrid6(501+ "001011",502+ "010101",503+ "001011", // Duplicate of row 0504+ "110100",505+ "100110",506+ "011001",507+ )508+ if hasUniqueLines(g, 6) {509+ t.Error("hasUniqueLines should return false for duplicate rows")510+ }511+ })512+513+ t.Run("duplicate columns", func(t *testing.T) {514+ g := makeGrid6(515+ "001011",516+ "001101",517+ "101010",518+ "101100",519+ "001011",520+ "001001",521+ )522+ // Columns 0 and 3 are both [0,0,1,1,0,0].523+ if hasUniqueLines(g, 6) {524+ t.Error("hasUniqueLines should return false for duplicate columns")525+ }526+ })527+528+ t.Run("partial duplicate still unique", func(t *testing.T) {529+ g := makeGrid6(530+ "001011",531+ "001101", // Shares prefix with row 0 but differs at end532+ "101010",533+ "110100",534+ "100110",535+ "011001",536+ )537+ if !hasUniqueLines(g, 6) {538+ t.Error("hasUniqueLines should return true when rows only partially match")539+ }540+ })541+}542+543+// --- Grid serialization (P1) ---544+545+func TestGridSerialization(t *testing.T) {546+ t.Run("6x6 round-trip", func(t *testing.T) {547+ g := makeGrid6("001011", "010101", "101010", "110100", "100110", "011001")548+ s := g.String()549+ g2 := newGrid(state(s))550+551+ if len(g2) != len(g) {552+ t.Fatalf("row count = %d, want %d", len(g2), len(g))553+ }554+ for y := range g {555+ for x := range g[y] {556+ if g2[y][x] != g[y][x] {557+ t.Errorf("cell (%d,%d) = %c, want %c", x, y, g2[y][x], g[y][x])558+ }559+ }560+ }561+ })562+563+ t.Run("8x8 round-trip", func(t *testing.T) {564+ s := createEmptyState(8)565+ g := newGrid(s)566+ s2 := g.String()567+ if string(s) != s2 {568+ t.Error("8x8 round-trip failed")569+ }570+ })571+572+ t.Run("all empty", func(t *testing.T) {573+ s := createEmptyState(6)574+ g := newGrid(s)575+ for y := range g {576+ for x := range g[y] {577+ if g[y][x] != emptyCell {578+ t.Errorf("cell (%d,%d) = %c, want emptyCell", x, y, g[y][x])579+ }580+ }581+ }582+ })583+}584+585+func TestGridClone(t *testing.T) {586+ original := makeGrid6("001011", "010101", "101010", "110100", "100110", "011001")587+ cloned := original.clone()588+589+ t.Run("content match", func(t *testing.T) {590+ for y := range original {591+ for x := range original[y] {592+ if cloned[y][x] != original[y][x] {593+ t.Errorf("cloned[%d][%d] = %c, want %c", y, x, cloned[y][x], original[y][x])594+ }595+ }596+ }597+ })598+599+ t.Run("deep copy", func(t *testing.T) {600+ cloned[0][0] = 'X'601+ if original[0][0] == 'X' {602+ t.Error("clone modified original grid")603+ }604+ })605+}606+607+func TestCreateEmptyState(t *testing.T) {608+ t.Run("size 6", func(t *testing.T) {609+ s := createEmptyState(6)610+ g := newGrid(s)611+ if len(g) != 6 {612+ t.Fatalf("row count = %d, want 6", len(g))613+ }614+ for y := range g {615+ if len(g[y]) != 6 {616+ t.Fatalf("col count at row %d = %d, want 6", y, len(g[y]))617+ }618+ for x := range g[y] {619+ if g[y][x] != emptyCell {620+ t.Errorf("cell (%d,%d) = %c, want emptyCell", x, y, g[y][x])621+ }622+ }623+ }624+ })625+626+ t.Run("size 0", func(t *testing.T) {627+ s := createEmptyState(0)628+ if s != "" {629+ t.Errorf("createEmptyState(0) = %q, want empty", s)630+ }631+ })632+}633+634+// --- serializeProvided / deserializeProvided (P1) ---635+636+func TestProvidedSerialization(t *testing.T) {637+ t.Run("round-trip", func(t *testing.T) {638+ p := [][]bool{639+ {true, false, true, false, true, false},640+ {false, true, false, true, false, true},641+ {true, true, false, false, true, true},642+ {false, false, true, true, false, false},643+ {true, false, true, false, true, false},644+ {false, true, false, true, false, true},645+ }646+ s := serializeProvided(p)647+ p2 := deserializeProvided(s, 6)648+649+ if len(p2) != 6 {650+ t.Fatalf("row count = %d, want 6", len(p2))651+ }652+ for y := range p {653+ for x := range p[y] {654+ if p2[y][x] != p[y][x] {655+ t.Errorf("provided[%d][%d] = %v, want %v", y, x, p2[y][x], p[y][x])656+ }657+ }658+ }659+ })660+661+ t.Run("all provided", func(t *testing.T) {662+ p := make([][]bool, 3)663+ for y := range p {664+ p[y] = []bool{true, true, true}665+ }666+ s := serializeProvided(p)667+ if s != "###\n###\n###" {668+ t.Errorf("serializeProvided = %q, want ###\\n###\\n###", s)669+ }670+ })671+672+ t.Run("none provided", func(t *testing.T) {673+ p := make([][]bool, 3)674+ for y := range p {675+ p[y] = []bool{false, false, false}676+ }677+ s := serializeProvided(p)678+ if s != "...\n...\n..." {679+ t.Errorf("serializeProvided = %q, want ...\\n...\\n...", s)680+ }681+ })682+683+ t.Run("checkerboard", func(t *testing.T) {684+ p := [][]bool{685+ {true, false},686+ {false, true},687+ }688+ s := serializeProvided(p)689+ expected := "#.\n.#"690+ if s != expected {691+ t.Errorf("serializeProvided = %q, want %q", s, expected)692+ }693+ p2 := deserializeProvided(s, 2)694+ for y := range p {695+ for x := range p[y] {696+ if p2[y][x] != p[y][x] {697+ t.Errorf("deserialized[%d][%d] = %v, want %v", y, x, p2[y][x], p[y][x])698+ }699+ }700+ }701+ })702+}703+704+// --- fillGrid (P2) ---705+706+func TestFillGrid(t *testing.T) {707+ t.Run("fills 6x6", func(t *testing.T) {708+ g := newGrid(createEmptyState(6))709+ ok := fillGrid(g, 6)710+ if !ok {711+ t.Fatal("fillGrid returned false")712+ }713+714+ // Check no empty cells.715+ for y := range g {716+ for x := range g[y] {717+ if g[y][x] == emptyCell {718+ t.Errorf("cell (%d,%d) is still empty", x, y)719+ }720+ }721+ }722+ })723+724+ t.Run("result valid", func(t *testing.T) {725+ g := newGrid(createEmptyState(6))726+ fillGrid(g, 6)727+ if !checkConstraints(g, 6) {728+ t.Error("fillGrid produced grid that fails checkConstraints")729+ }730+ })731+732+ t.Run("result has unique lines", func(t *testing.T) {733+ g := newGrid(createEmptyState(6))734+ fillGrid(g, 6)735+ if !hasUniqueLines(g, 6) {736+ t.Error("fillGrid produced grid with duplicate lines")737+ }738+ })739+}740+741+// --- countSolutions (P2) ---742+743+func TestCountSolutions(t *testing.T) {744+ t.Run("complete valid grid", func(t *testing.T) {745+ g := validGrid6()746+ count := countSolutions(g, 6, 10)747+ if count != 1 {748+ t.Errorf("countSolutions = %d, want 1", count)749+ }750+ })751+752+ t.Run("one empty cell unique", func(t *testing.T) {753+ g := validGrid6()754+ // Remove one cell.755+ saved := g[0][0]756+ g[0][0] = emptyCell757+ count := countSolutions(g, 6, 10)758+ if count != 1 {759+ t.Errorf("countSolutions = %d, want 1", count)760+ }761+ g[0][0] = saved762+ })763+764+ t.Run("multiple solutions with limit", func(t *testing.T) {765+ g := newGrid(createEmptyState(6))766+ // Empty grid has many solutions, should hit limit.767+ count := countSolutions(g, 6, 2)768+ if count < 2 {769+ t.Errorf("countSolutions = %d, want >= 2", count)770+ }771+ })772+773+ t.Run("respects limit", func(t *testing.T) {774+ g := newGrid(createEmptyState(6))775+ count := countSolutions(g, 6, 3)776+ if count > 3 {777+ t.Errorf("countSolutions = %d, should not exceed limit 3", count)778+ }779+ })780+}781+782+// --- generatePuzzle (P2) ---783+784+func TestGeneratePuzzle(t *testing.T) {785+ complete := generateComplete(6)786+787+ t.Run("unique solution", func(t *testing.T) {788+ puzzle, _ := generatePuzzle(complete, 6, 0.5)789+ count := countSolutions(puzzle, 6, 2)790+ if count != 1 {791+ t.Errorf("generated puzzle has %d solutions, want 1", count)792+ }793+ })794+795+ t.Run("provided mask consistent with empties", func(t *testing.T) {796+ puzzle, provided := generatePuzzle(complete, 6, 0.5)797+ for y := range puzzle {798+ for x := range puzzle[y] {799+ isEmpty := puzzle[y][x] == emptyCell800+ isProvided := provided[y][x]801+ if isEmpty == isProvided {802+ t.Errorf("cell (%d,%d): isEmpty=%v but isProvided=%v", x, y, isEmpty, isProvided)803+ }804+ }805+ }806+ })807+808+ t.Run("approximate prefill", func(t *testing.T) {809+ _, provided := generatePuzzle(complete, 6, 0.5)810+ count := 0811+ for y := range provided {812+ for x := range provided[y] {813+ if provided[y][x] {814+ count++815+ }816+ }817+ }818+ target := 18 // 50% of 36819+ if count < target-5 || count > target+5 {820+ t.Errorf("provided cell count = %d, want ~%d (±5)", count, target)821+ }822+ })823+}824+825+// --- Save/Load round-trip (P1) ---826+827+func TestSaveLoadRoundTrip(t *testing.T) {828+ puzzle := makeGrid6("00101.", "01....", "......", "......", "......", "......")829+ provided := make([][]bool, 6)830+ for y := range 6 {831+ provided[y] = make([]bool, 6)832+ for x := range 6 {833+ provided[y][x] = puzzle[y][x] != emptyCell834+ }835+ }836+837+ m := Model{838+ size: 6,839+ grid: puzzle,840+ provided: provided,841+ cursor: game.Cursor{X: 2, Y: 3},842+ keys: DefaultKeyMap,843+ modeTitle: "Medium",844+ solved: false,845+ }846+847+ data, err := m.GetSave()848+ if err != nil {849+ t.Fatal(err)850+ }851+852+ got, err := ImportModel(data)853+ if err != nil {854+ t.Fatal(err)855+ }856+857+ // Size.858+ if got.size != 6 {859+ t.Errorf("size = %d, want 6", got.size)860+ }861+862+ // Grid content.863+ if got.grid.String() != m.grid.String() {864+ t.Error("grid mismatch")865+ }866+867+ // Provided matrix.868+ for y := range provided {869+ for x := range provided[y] {870+ if got.provided[y][x] != provided[y][x] {871+ t.Errorf("provided[%d][%d] = %v, want %v", y, x, got.provided[y][x], provided[y][x])872+ }873+ }874+ }875+876+ // Mode title.877+ if got.modeTitle != "Medium" {878+ t.Errorf("modeTitle = %q, want %q", got.modeTitle, "Medium")879+ }880+}881+882+func TestImportModel_InvalidJSON(t *testing.T) {883+ _, err := ImportModel([]byte("not json"))884+ if err == nil {885+ t.Fatal("expected error for invalid JSON")886+ }887+}888+889+func TestImportModel_InvalidSize(t *testing.T) {890+ data, _ := json.Marshal(Save{Size: 0})891+ _, err := ImportModel(data)892+ if err == nil {893+ t.Fatal("expected error for invalid size")894+ }895+}896+897+func TestImportModel_SolvedRecalculated(t *testing.T) {898+ g := validGrid6()899+ provided := make([][]bool, 6)900+ for y := range 6 {901+ provided[y] = make([]bool, 6)902+ }903+904+ data, _ := json.Marshal(Save{905+ Size: 6,906+ State: g.String(),907+ Provided: serializeProvided(provided),908+ })909+910+ got, err := ImportModel(data)911+ if err != nil {912+ t.Fatal(err)913+ }914+915+ if !got.solved {916+ t.Error("ImportModel should set solved=true for complete valid grid")917+ }918+}